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

 

1. 단위 테스트란?

  단위 테스트는 특정 단위(테스트 대상)가 의도한대로 작동하는지 검증하는 테스트를 말한다. 이때 단위에 대한 정의는 하는 사람마다 조금씩 다를 수 있으나, 소프트웨어 시스템의 작은 부분에 초점을 맞춘 저수준이라는 개념이다. 단위 테스트는 협력 객체를 어떻게 다루는 지에 따라 2가지로 분류할 수 있다.

2. 통합과 고립

 단위 테스트는 협력 객체에 실제 객체를 사용하는지(통합), 아니면 가짜 객체를 사용하는지(고립)에 따라 2가지로 분류된다. 아래 예시를 살펴보자. Line(노선)에 대해 Station(역)이 협력객체인 상황이다.

class LineTest {

    @Test
    void 실제_객체로_테스트() {
        final Station 송내역 = new Station("송내역", true);
        final Station 신도림역 = new Station("신도림역", true);
        final Line 일호선 = new Line("1호선", 송내역, 신도림역);

        assertThat(일호선.getOpenedStations()).hasSize(2);
    }

    @Test
    void 가짜_객체로_테스트() {
        final Station 송내역 = mock(Station.class);
        final Station 신도림역 = mock(Station.class);
        when(송내역.isOpened()).thenReturn(true);
        when(신도림역.isOpened()).thenReturn(true);
        final Line 일호선 = new Line("1호선", 송내역, 신도림역);

        assertThat(일호선.getOpenedStations()).hasSize(2);
    }
}
public class Line {
    private Long id;
    private String name;
    private List<Station> stations = new ArrayList<>();

    public Line(final String name, final Station upStation, final Station downStation) {
        this.name = name;
        stations.add(upStation);
        stations.add(downStation);
    }

    public List<Station> getOpenedStations() {
        return stations.stream()
            .filter(Station::isOpened)
            .collect(Collectors.toList());
    }
}

public class Station {
    private Long id;
    private String name;
    private boolean opened;
    
    public Station(final String name, final boolean opened) {
        this.name = name;
        this.opened = opened;
    }

    public Long getId() {
        return id;
    }

    public String getName() {
        return name;
    }

    public boolean isOpened() {
        return opened;
    }
}

실제 객체를 사용할 경우

  • 실제 객체를 사용할 경우, 협력 객체의 상세 구현에 대해선 알 필요가 없다.
  • 하지만 협력 객체의 정상 동작 여부에 해당 단위 테스트의 결과가 영향을 받는다.

가짜 객체를 사용할 경우

  • 테스트 대상을 검증할 때, 외부 요인(협력 객체)으로부터 철저히 격리된다.
  • 하지만 테스트가 협력 객체의 상세 구현에 의존하게 된다.

3. TDD 접근 방식

 TDD 접근 방식에는 Outside In 방식과 Inside Out 방식이 있다.

 

 1) Outside In : 시스템 외부 요청에 대한 테스트부터 작성하며 개발을 시작하는 방식이다. 테스트 코드 작성 시, 협력 객체를 가짜 객체로 만들어 개발을 이어 나간다. 

 2) Inside Out : 도메인 설계를 한 후, 의존 관계를 갖지 않는 가장 내부의 객체들에 대한 테스트 코드부터 작성해나간다. 테스트 코드 작성이 완료되면, 그 객체를 협력 객체로 가지는 객체에 대한 테스트 코드를 작성함으로써 점점 바깥 방향으로 이어 나간다.

 

 둘 중 어느 것을 선택해야하는 문제가 아니다. 상황에 따라 적절한 방식을 취하도록 한다.

사실은 상향식, 하향식 둘 다 TDD의 프로세스를 효과적으로 설명해 줄 수 없다. 만약 어떤 방향성을 가질 필요가 있다면 '아는 것에서 모르는 것으로(known-to-unknown)' 방향이 유용할 것이다. 우리가 어느 정도의 지식과 경험을 가지고 시작한다는 점, 개발하는 중에 새로운 것을 배우게 될 것임을 예상한다는 점 등을 암시한다.
- Test-Driven Development, kent beck -

4. 구현 방식

 이 프로젝트에서는 인수 테스트 코드를 통해 요구사항과 기능 전반에 대한 이해를 선행하고(Outside In), 내부 구현에 대해서는 클래스 설계를 통해 안쪽부터 구현을 해나가는 방식(Inside Out)을 택했다.

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

 

1. TDD란?

  • TDD란 Test Driven Development의 약자로, 테스트 주도 개발이라는 개발 방법론이다.
  • TDD = TFD(Test First Development) + 리팩토링

 TDD의 장점은 아래와 같다.

 1) 디버깅 시간을 줄여준다.

 2) 동작하는 문서의 역할을 한다.

 3) 변화에 대한 두려움을 줄여준다.

 

2. TDD 사이클

 1) 실패하는 테스트를 구현한다.

 2) 테스트가 성공하도록 프로덕션 코드를 구현한다.

 3) 프로덕션 코드와 테스트 코드를 리팩토링한다.

 TDD로 개발 시, 이와 같은 과정을 계속 반복하여 개발을 진행한다.

3. TDD 원칙

 1) 실패하는 단위 테스트를 작성할 때까지 프로덕션 코드(production code)를 작성하지 않는다.
 2) 컴파일은 실패하지 않으면서 실행이 실패하는 정도로만 단위 테스트를 작성한다.
 3) 현재 실패하는 테스트를 통과할 정도로만 실제 코드를 작성한다.

 

4. 클래스 설계

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

 위 목록은 구현해야할 기능들이다. 구현할 로또 기능들의 도메인 클래스들을 대략적으로 설계해봤다. 이는 '실패하는 단위 테스트를 작성할 때까지 프로덕션 코드(production code)를 작성하지 않는다.'는 TDD의 원칙을 어기는 것처럼 보일 수도 있다. 근데 대략적으로라도 클래스 설계를 해야 테스트 코드를 시작할 수 있지 않을까?;; 대략적인 클래스 설계가 목적이기 때문에, 일급 컬렉션이나 원시값 포장 등은 고려하지 않았다.

 

@Entity
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Purchase {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    private BigDecimal payment;

    private int round;

    private Long userId;

    @OneToMany(mappedBy = "purchase", fetch = FetchType.LAZY, cascade = CascadeType.PERSIST)
    private List<Lotto> lottos = new ArrayList<>();

    private LocalDateTime createdDate;
}

@Entity
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Lotto {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    private List<Integer> numbers;

    private LottoType type;

    @ManyToOne(fetch = FetchType.LAZY)
    private PaymentHistory paymentHistory;
}

enum LottoType {
    MANUAL, AUTO
}

 연관관계를 어떻게 설정해야할지 많은 고민을 했다. 일반적으로는 다대일 단방향을 추천하곤 한다. 근데 이 경우에는 Purchase와 Lotto가 항상 같은 라이프 사이클을 가질 것이고, 유효성 검사나 기능 구현 측면에서 봤을 때 Purchase가 Lotto를 참조할 필요가 있어보였다. 그렇다고 일대다 단방향으로 하기엔, Lotto 저장 시 insert, update 2번의 쿼리가 발생할 것이고, 이는 성능상 이슈가 될 수 있을 것 같았다. 결국 좀 까다롭지만, 양방향 연관관계를 설정하기로 했다. 

 

5. 테스트 코드 작성

 실패한 테스트 코드 작성까지 완료한 코드는 아래와 같다.

@Entity
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Purchase {
    static final int LOTTO_PRICE = 1000;
    static final String PAYMENT_ERR_MSG = "지불 금액은 " + LOTTO_PRICE + "의 배수이어야 합니다.";
    static final String OVER_MANUAL_LOTTOS_ERR_MSG = "지불 금액에 비해 수동으로 입력된 로또의 수가 너무 많습니다.";
    static final String PURCHASE_NOT_ALLOWED_ERR_MSG = "토요일 오후 8시 ~ 9시에는 구매가 불가능합니다.";

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

    private BigDecimal payment;

    private int round;

    private Long userId;

    @OneToMany(mappedBy = "purchase", fetch = FetchType.LAZY, cascade = CascadeType.PERSIST)
    private List<Lotto> lottos = new ArrayList<>();

    private LocalDateTime createdDate;

    Purchase(
        final int payment,
        final int round,
        final Long userId,
        final List<Collection<Integer>> manualNumbers
    ) {
        this(payment, round, userId, manualNumbers, LocalDateTime.now());
    }

    Purchase(
        final int payment,
        final int round,
        final Long userId,
        final List<Collection<Integer>> manualNumbers,
        final LocalDateTime createdDate
    ) {

    }

    public List<Lotto> getLottos() {
        return Collections.unmodifiableList(lottos);
    }
}

@Entity
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Lotto {
    static final String INVALID_NUMBER_ERR_MSG = "로또의 번호들은 1~45까지의 중복되지 않는 숫자 6개로 이루어져 있어야 합니다.";

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

    private List<Integer> numbers = new ArrayList<>();

    private LottoType type;

    @ManyToOne(fetch = FetchType.LAZY)
    private Purchase purchase;

    Lotto(final Collection<Integer> numbers, final LottoType type, final Purchase purchase) {

    }

    boolean isAuto() {
        return type == LottoType.AUTO;
    }

    public List<Integer> getNumbers() {
        return Collections.unmodifiableList(numbers);
    }
}
class PurchaseTest {
    @Test
    @DisplayName("지불 금액에서 수동으로 입력된 로또의 수를 제외한 나머지에 해당하는 수만큼 자동으로 로또가 생성되는지 테스트")
    void create() {
        // when
        final Purchase purchase = new Purchase(10000, 1, 1L, List.of(Set.of(1, 2, 3, 4, 5, 6)));

        // then
        assertThat(purchase.getLottos()).filteredOn(Lotto::isAuto).hasSize(9);
    }

    @ParameterizedTest
    @ValueSource(ints = {999, 1001})
    @DisplayName("지불 금액이 " + LOTTO_PRICE + "원 미만이거나 " + LOTTO_PRICE + "원으로 나누어 떨어지지 않는 경우 예외 발생")
    void createByInvalidPayment(final int invalidPayment) {
        // when, then
        assertThatThrownBy(() -> new Purchase(invalidPayment, 1, 1L, List.of(Set.of(1, 2, 3, 4, 5, 6))))
            .isInstanceOf(BadRequestException.class)
            .hasMessageContaining(PAYMENT_ERR_MSG);
    }

    @Test
    @DisplayName("지불 금액에 비해 입력되는 로또의 수가 많을 때 예외 발생")
    void createByTooManyManualLottos() {
        // when, then
        assertThatThrownBy(() ->
            new Purchase(1000, 1, 1L, List.of(Set.of(1, 2, 3, 4, 5, 6), Set.of(7, 8, 9, 10, 11, 12))))
            .isInstanceOf(BadRequestException.class)
            .hasMessageContaining(OVER_MANUAL_LOTTOS_ERR_MSG);
    }

    @Test
    @DisplayName("토요일 오후 8시 ~ 9시에는 구매 시 예외 발생")
    void createByInvalidCreatedDate() {
        // given (토요일 오후 8시)
        final LocalDateTime invalidCreatedDate = LocalDateTime.of(2022, 3, 26, 20, 0);

        // when, then
        assertThatThrownBy(() ->
            new Purchase(10000, 1, 1L, List.of(Set.of(1, 2, 3, 4, 5, 6)), invalidCreatedDate))
            .isInstanceOf(BadRequestException.class)
            .hasMessageContaining(PURCHASE_NOT_ALLOWED_ERR_MSG);
    }
}

class LottoTest {
    private static final Purchase DEFAULT_PURCHASE =
        new Purchase(1000, 1, 1L, List.of(Set.of(1, 2, 3, 4, 5, 6)));

    @Test
    void create() {
        // when
        final Lotto lotto = new Lotto(List.of(6, 1, 3, 2, 4, 5), LottoType.MANUAL, DEFAULT_PURCHASE);

        // then
        assertThat(lotto.getNumbers()).containsExactly(1, 2, 3, 4, 5, 6);
    }

    @ParameterizedTest
    @MethodSource("provideInvalidNumbers")
    @DisplayName("로또의 번호들이 1~45까지의 중복되지 않는 숫자 6개로 이루어져 있지 않을 때 예외 발생")
    void createByInvalidNumbers(final List<Integer> invalidNumbers) {
        // when, then
        assertThatThrownBy(() -> new Lotto(invalidNumbers, LottoType.MANUAL, DEFAULT_PURCHASE))
            .isInstanceOf(BadRequestException.class)
            .hasMessageContaining(INVALID_NUMBER_ERR_MSG);
    }

    private static Stream<List<Integer>> provideInvalidNumbers() {
        return Stream.of(
            List.of(1, 2, 3, 4, 5, 46),
            List.of(1, 2, 3, 4, 5, 5),
            List.of(1, 2, 3, 4, 5),
            List.of(1, 2, 3, 4, 5, 6, 7)
        );
    }
}

 

 오늘은 실패한 테스트 코드까지 작성해봤다. 다음엔 테스트 코드가 성공하도록 프로덕션 코드를 작성하면서 TDD 사이클을 진행해보겠다.

 이 글은 내가 우아한테크캠프에서 배웠던 내용들을 복습하기 위해 진행하는 토이프로젝트(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 파일에 정리한 기능 목록 단위로 추가한다.
* 커밋 메시지는 의미있게 작성한다.

 

+ Recent posts