Changsoon's Note Backend Developer

Fixture Monkey 도입 과정

프로젝트에 Fixture Monkey를 도입 과정을 알아보자.

Fixture란?

주로 Test Fixture 라고 많이 이야기하는 Fixture 대해서 알아보자.

Fixture란 테스트 환경을 준비하거나 특정 조건을 설정하는 데 사용되는 코드나 데이터를 의미한다.

BDD 테스트의 given when then 단계의 given 단계라고 할 수 있다.

보통 이러한 데이터는 setup 단계에서 정의하거나 단위 테스트 안에서 작성하는 경우가 많다.

import org.junit.jupiter.api.Test;

import static org.junit.jupiter.api.Assertions.assertEquals;

public class SampleTest {

    @Test
    void sample1() {
        // given, fixture 생성
        Member a = new Member("a", 10);
        Member b = new Member("b", 10);

        // when
        int result = a.getAge() + b.getAge();

        // then
        assertEquals(20, result);
    }
}

위와 같이 Member 인스턴스 a, b를 Test Fixture라고 한다.

Fixture 작성의 문제점

위의 예제를 봤을 때 Fixture 작성의 문제점은 딱히 없어보인다.

단순히 두 개의 인스턴스를 생성하고 각 값을 대입한 후 테스트에 사용한다.

하지만 이러한 Fixture가 많이 필요하고, 복잡한 상태에, 데이터의 파라미터도 많다면 문제가 발생한다.

예시로 알아보자.

위의 예제에서 Member 객체를 수정해보자. 지금은 Member 객체의 필드가 2개로 구성되어 있는데, 이를 10개로 늘려보자.

import org.junit.jupiter.api.Test;

import static org.junit.jupiter.api.Assertions.assertEquals;

public class SampleTest {

    @Test
    void sample1() {
        // given, fixture 생성
        Member a = new Member("a", 10, "a", "b", "c", "d", "e", "f", "g", "e");
        Member b = new Member("b", 10, "a", "b", "c", "d", "e", "f", "g", "e");

        // when
        int result = a.getAge() + b.getAge();

        // then
        assertEquals(20, result);
    }
}

단순 필드만 몇 개 추가했을 뿐인데, 머리가 아파온다.

제대로 데이터가 들어갔는지 확인하기도 어렵고, 가독성도 떨어진다.

이러한 Fixture가 여러 테스트 코드에 걸쳐 필요하다고 한다면 복잡함은 배가 된다.

위의 데이터는 간단한 구조라 그나마 괜찮지만 복잡한 구조를 포함하고 있다면 더욱 만들기 어려워진다.

예를 들어, Member 객체에 Address 객체를 포함시켜보자.

public class Address {
    private Location location;
    private Double latitude;
    private Double longitude;
    // getter, setter
}

...

public class Location {
  private String first;
  private String second;
  // getter, setter
}
import org.junit.jupiter.api.Test;

import static org.junit.jupiter.api.Assertions.assertEquals;

public class SampleTest {

    @Test
    void sample1() {
        // given, fixture 생성
        Location locationA = new Location("a", "b");
        Address addressA = new Address(locationA, 10.0, 20.0);
        Member a = new Member("a", 10, "a", "b", "c", "d", "e", "f", "g", "e", addressA);

        Location locationB = new Location("a", "b");
        Address addressB = new Address(locationB, 10.0, 20.0);
        Member b = new Member("b", 10, "a", "b", "c", "d", "e", "f", "g", "e", addressB);

        // when
        int result = a.getAge() + b.getAge();

        // then
        assertEquals(20, result);
    }
}

이렇게 복잡한 객체가 Fixture 안에 포함되면 Fixture를 만들기 복잡해진다.

이러한 Fixture가 List와 같은 자료구조 형태라면 더더욱 복잡해진다.

마지막으로 가장 큰 문제는 변경에 취약하다는 것이다.

예를 들어, Member의 첫 번째 필드를 String -> Long으로 변경한다라고 가정해보자.

그렇다면 모든 테스트 코드의 Fixture를 일일이 수정해야 한다.

@BeforeEach와 같은 곳에서 정의한다면 그나마 낫지만 그래도 번거롭다.

문제점을 정리해보자.

  • Fixture 작성에 비용(시간)이 많이 발생한다.
  • 필드가 많은 경우 추가적인 비용이 발생하고 가독성이 떨어진다.
  • 다른 객체를 포함하거나 자료구조를 가진 복잡한 Fixture를 작성하기 번거롭다.
  • 데이터 객체가 변경된다면 Fixture를 일일이 수정해야 한다.

이러한 문제점을 해결하기 위해 Fixture Monkey를 도입한다.

Fixture Monkey란?

Fixture Monkey는 Naver에서 개발한 테스트 데이터 생성 라이브러리이다.

테스트 데이터를 자동 생성해주고 테스트 코드 간소화에 도움을 준다.

먼저 의존성을 추가해주자.

testRuntimeOnly("org.junit.platform:junit-platform-launcher:{version}")
testImplementation("com.navercorp.fixturemonkey:fixture-monkey-starter:1.1.9")

fixture monkey를 사용하려면 먼저 팩토리를 만들어야 한다.

@Test
void fixtureMonkeyTest() {
    FixtureMonkey monkey = FixtureMonkey.create();
}

여기서 단일 객체 생성을 원한다면 giveMeOne(클래스) 메서드를 사용하면 된다.

Fixture 생성 전략에 따라 Fixture로 만들 객체에 @Getter, @Setter, 생성자가 필요할 수도 있다.

아래와 같이 생성해주면 라이브러리가 알아서 해당 객체 안의 필드를 채워서 인스턴스를 생성해준다.

@Test
void fixtureMonkeyTest() {
    FixtureMonkey monkey = FixtureMonkey.create();
    Member member = monkey.giveMeOne(Member.class);
    System.out.println(member.getName()); // ㋰볽
}

List와 같은 자료구조 Fixture도 쉽게 만들 수 있다.

@Test
void listFixtureTest() {
    FixtureMonkey monkey = FixtureMonkey.create();
    List<Member> members = monkey.giveMe(Member.class, 3);
}

또한, 위의 직접 Fixture를 생성할 때의 문제점인 변경에 취약한 문제도 사라진다.

데이터 객체를 변경해도 Fixture Monkey 코드에서 직접 수정해야 할 것이 없기 떄문에 이 문제가 해결된다.

물론, 꼭 필요한 데이터를 직접 입력할 수도 있다.

@Test
void selectDataFixtureTest() {
    Member member = monkey.giveMeBuilder(Member.class)
            .set("name", "testName")
            .sample();
    System.out.println(member.getName()); // testName
}

추가로, 계속해서 FixtureMonkey.create()를 하지 않으려면 추상 클래스를 정의해놓고 사용해도 된다.

import com.navercorp.fixturemonkey.FixtureMonkey;
import com.navercorp.fixturemonkey.api.introspector.BeanArbitraryIntrospector;
import com.navercorp.fixturemonkey.api.introspector.BuilderArbitraryIntrospector;
import com.navercorp.fixturemonkey.api.introspector.FailoverIntrospector;
import com.navercorp.fixturemonkey.api.introspector.FieldReflectionArbitraryIntrospector;

import java.util.Arrays;

public abstract class FixtureSupport {
    public static FixtureMonkey monkey = FixtureMonkey.builder()
            .objectIntrospector(new FailoverIntrospector(
                    Arrays.asList(
                            FieldReflectionArbitraryIntrospector.INSTANCE,
                            BeanArbitraryIntrospector.INSTANCE,
                            BuilderArbitraryIntrospector.INSTANCE
                    )
            ))
            .defaultNotNull(true)
            .build();
}

이렇게 Fixture Monkey를 도입함으로써, 직접 Fixture를 생성했을 때의 문제를 해결했다.

  • Fixture 작성에 비용(시간)이 많이 발생한다. -> 간단하게 Fixture를 생성할 수 있게 됨
  • 필드가 많은 경우 추가적인 비용이 발생하고 가독성이 떨어진다. -> 단순히 monkey.giveMeXXX로 작성해서 비용 감소와 가독성 향상
  • 다른 객체를 포함하거나 자료구조를 가진 복잡한 Fixture를 작성하기 번거롭다. -> 복잡한 객체를 Fixture Monkey가 알아서 생성해준다.
  • 데이터 객체가 변경된다면 Fixture를 일일이 수정해야 한다. -> 변경이 되더라도 테스트 코드에는 변경이 없음

String의 isEmpty(), isBlank(), null 체크

자바에서 String 문자열을 다룰 때 빈 값이나 null을 체크하는 기능을 사용하곤 한다.

각각을 알아보고, 정리해보자.

isEmpty()

String 클래스의 isEmpty() 메서드는 문자열이 빈 문자열(““)인지 확인한다.

내부 구조를 확인해보면, 문자열의 길이가 0인지를 확인한다.

...
@Override
public boolean isEmpty() {
  return value.length == 0;
}
...

따라서 빈 문자열의 길이는 0이니 true가 출력되고 나머지는 false가 출력된다.

String str = "";
str.isEmpty(); // true

isEmpty()로 null 값도 체크할 수 있을까?

당연히 되지 않는다. 왜냐하면 isEmpty를 호출할 때 해당 String 객체의 참조값이 필요한데, null.isEmpty()를 사용하게 되면 NullPointException이 발생하기 때문이다.

String str = null;
str.isEmpty(); // NullPointException

isBlank()

String 클래스의 isBlank() 메서드는 문자열이 빈 문자열인지, 아니면 공백만으로 이루어져 있는지를 체크한다.

isBlank()는 isEmpty()에 공백도 체크하는 기능을 추가로 가지고 있는 것이라고 생각하면 된다.

String str = "";
str.isBlank(); // true

String str2 = "   ";
str.isBlank(); // true

isBlank()의 내부 구현을 살펴보면 공백이 아닌 첫 번째 인덱스와 전체 길이가 같은지 체크한다.

public boolean isBlank() {
    return indexOfNonWhitespace() == length();
}

isEmpty()와 마찬가지로 null 값에 사용하면 NullPointException이 발생한다.

String == null

String 값을 직접 null로 비교하면 null 값을 체크할 수 있다.

String str = null;

boolean result = (a == null); // true

String str = null이라는 코드는 str의 참조를 null로 둔다는 것이고 a == null은 두 값의 참조를 비교하기 때문에, 좌항의 값은 a의 참조인 null이 되고 우항의 값은 null이니 값이 같아 true가 출력되게 된다.

Objects 클래스의 isNull, nonNull 메서드를 통해서도 체크할 수 있다.

String str = null;
Objects.isNull(a); // true
Objects.nonNull(a); // false

String.equals(null)

String 값에 equals() 메서드를 사용하면 isBlank() isEmpty()와 마찬가지로 NullPointException이 발생한다.

정리

  • null 체크를 할 때는 다음을 사용하자.
    • Objects.isNull()
    • Objects.nonNull()
    • String == null
  • 빈 문자열을 체크할 때는 다음을 사용하자.
    • isEmpty()
    • isBlank()
  • 빈 문자열을 체크하고 공백만으로 이루어져 있는지 체크할 때는 다음을 사용하자.
    • isBlank()

Kotlin data class

코틀린 data class란?

먼저 코틀린의 data class에 대해서 알아보자.

코틀린 data class란 데이터를 저장하는 데 사용되는 클래스로, 자동으로 여러 기능을 제공하는 클래스다.

data class Person(val name: String, val age: Int)

일반적으로 데이터를 표현하는 객체를 생성할 때 유용하게 사용된다.

data class는 자동으로 메서드들이 생성되는데, toString(), equals(), hashCode(), copy(), componentN()이 있다.

data class를 자바에서 표현하자면 다음과 같다.

public class Person {
    private final String name;
    private final int age;

    public Person(String name, int age) {
        this.name = name;
        this.age = age;
    }

    public String getName() {
        return name;
    }

    public int getAge() {
        return age;
    }

    @Override
    public String toString() {
        return "Person{name='" + name + "', age=" + age + '}';
    }

    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (o == null || getClass() != o.getClass()) return false;
        Person person = (Person) o;
        return age == person.age && Objects.equals(name, person.name);
    }

    @Override
    public int hashCode() {
        return Objects.hash(name, age);
    }

    // copy(), 구조 분해 메서드 추가
}

data class는 구조 분해 선언을 제공한다. 각각의 프로퍼티에 대응하는 지역 변수를 정의하는 간결한 구문을 제공한다.

val (name, age) = Person("name", 10)

println(name)

data class와 불변 객체

불변 객체는 한 번 생성된 후 그 상태나 값이 변경될 수 없는 객체를 말한다.

불변 객체는 객체의 내용을 수정하거나 업데이트하는 것이 불가능한 대신에 새로운 객체를 만들어야 한다.

불변 객체는 여러 스레드에서 동시에 접근하더라도 값이 바뀌지 않기 때문에, 멀티스레드 환경에서 유리하다.

코틀린 data class를 불변으로 만들려면, 모든 프로퍼티를 val로 만든다.

그리고 data class는 copy() 메서드를 제공해서 편리하게 불변 객체를 활용할 수 있게 해준다.

객체를 메모리상에서 직접 바꾸는 대신 복사본을 만들어, 원본과 다른 생명주기를 가지며, 일부 프로퍼티 값을 바꾸거나 복사본을 제거해도 프로그램 원본을 참조하는 다른 부분에 영향을 끼치지 않는다.

data class Person(val name: String, val age: Int)

fun main() {
  val person1 = Person("test", 25)

  val person2 = person1.copy(age = 26)

  println(person1)  // Person(name=test, age=25)
  println(person2)  // Person(name=test, age=26)
}

copy() 메서드 주의점 : 얕은 복사

copy() 메서드는 얕은 복사를 하기 때문에 data class의 프로퍼티가 기본형이 아니라면 주의해야 한다.

data class Person(val name: String, var age: Int)

fun main() {
    val person1 = Person("test", 25)

    val person2 = person1.copy()
    person2.age = 10

    println(person1.toString()) // Person(name=test, age=25)
}

위 코드는 age의 타입이 Int 기본형이기 때문에 복사본의 age 값을 변경해도 원본의 값이 변경되지 않는다.

data class Person(val name: String, val friends: MutableList<String>)

fun main() {
  val person1 = Person("a", mutableListOf("b", "c"))
  val person2 = person1.copy()
  person2.friends.add("d")
  println(person1) // Person(name=a, friends=[b, c, d])
}

객체 내의 필드 값들이 참조 타입일 경우 그 참조만 복사한다. 그래서 위 코드의 MutableList의 참조값만 복사하기 때문에 원본 객체의 값이 변경된다.

Random과 SecureRandom

SonarQube를 이용해서 코드를 검토하던 중 Random 클래스를 사용하는 부분에 경고가 나왔다.

왜 그런지 알아보자.

Random 클래스

Random() 메서드는 의사난수 생성기로, 예측 가능한 방식으로 난수를 생성한다.

따라서 보안이 중요한 애플리케이션에서 Random 클래스를 사용하는 것은 위험할 수 있다.

Random 클래스는 System.currentTimeMillis()나 System.nanoTime()을 기반으로 시드(seed)를 초기화하는데, 이러한 값들은 시간이 흐르면 예측할 수 있는 패턴이 발생할 수 있다.

예를 들어, 두 개의 Random 객체가 동일한 시점에 생성되면 동일한 시드를 공유하게 되어 예측할 수 있는 것이다.

import java.util.Random;

public class Main {
    public static void main(String[] args) {
        Random random = new Random(42);
        for (int i = 0; i < 5; i++) {
            System.out.println(random.nextInt(100));
        }
    }
}

위와 같은 코드를 실행하면 같은 결과가 계속 나온다.

// 똑같은 값만 나옴
30
63
48
84
70

결국 Random 클래스의 난수 생성은 시드 값에 의존하며, 시드 값이 고정되거나 예측 가능하면 생성된 난수도 예측할 수 있다.

SecureRandom 클래스

SecureRandom은 보안 시스템의 하드웨어나 운영체제의 보안 엔진에서 제공되는 진정한 무작위성을 기반으로 난수를 생성한다.

SecureRandom은 시스템에서 발생하는 무작위 데이터를 모은 저장소, 엔트로피 풀을 사용해서 난수를 생성한다.

또한, SecureRandom은 암호학적 난수 생성기로 설계되 어 있다. Random 클래스의 의사 난수 생성기는 예측 가능한 반면, 암호학적 난수 생성기는 암호학적으로 안전한 알고리즘을 사용하여 난수를 예측하는 것이 매우 어렵다.

import java.security.SecureRandom;

public class Main {
    public static void main(String[] args) {
        SecureRandom secureRandom = new SecureRandom();

        for (int i = 0; i < 5; i++) {
            System.out.println(secureRandom.nextInt(100));
        }
    }
}
// 여러 번 실행해도 결과가 같지 않다.

// 1번 실행
86
42
9
61
21

// 2번 실행
63
15
62
87
66

POST 요청은 GET 요청보다 보안성이 뛰어날까?

대부분 HTTP에 관한 글에는 HTTP Method를 비교를 한다.

여기에는 post 요청이 get 요청보다 보안성이 뛰어나다고 되어 있는데, 과연 그럴까?

왜 보안성이 뛰어나다고 하는 걸까?

get 요청은 데이터가 URL에 포함되므로 브라우저 기록이나 서버 로그, 방문한 페이지 등에 남을 가능성이 있다.

반면에, post 요청은 데이터가 HTTP 본문에 포함되므로 URL에 노출되지 않는다. 따라서 브라우저 기록이나 서버 로그에 직접적으로 남지 않는다.

다른 예로는, 브라우저에서 get 요청으로 전송된 데이터가 캐시된다면, 다른 사용자가 같은 브라우저를 사용할 때 민감한 정보가 노출될 수 있다는 것이 있다.

반면에 post 요청은 기본적으로 캐시되지 않는다고 한다.

따로 보안을 적용하지 않으면 그게 그거다.

위 내용은 맞는 말이지만 얼마든지 브라우저 기록으로 post 요청의 본문 내용을 확인할 수 있고, post 요청의 데이터도 캐시할 수 있다.

캐싱은 Cache-Control 헤더에서 no-store 하지 않는 이상 가능하고, post의 본문도 개발자 도구로 확인할 수 있다.

get 요청은 url에서 확인할 수 있다.

http://localhost:8080/test?password=password

post 요청은 개발자 도구에서 확인할 수 있다.

image.png

결론

get 과 post는 단순 전송 방식의 차이고, 보안성과는 연관이 크게 없다.

HTTPS나 다른 보안 요소를 적용하는 것이 보안성을 올리는 방법이다.