반응형

ITEM 40 "@Override 애너테이션을 일관되게 사용하라"

 

@Override 애너테이션은 해당 메서드가 상위 타입의 메서드를 재정의한다는 애너테이션이다.

메서드 선언에만 사용할 수 있고, 꼭 붙이지 않아도 되지만 재정의를 하려고 한다면 붙이는 것을 권장한다. 컴파일 타임 단계에서 자칫 오버로딩(Overloading) 으로 메서드를 선언하는 실수를 방지해준다.

 

public class Bigram {
    private final char first;
    private final char second;

    public Bigram(char first, char second) {
        this.first  = first;
        this.second = second;
    }

    public boolean equals(Bigram b) {
        return b.first == first && b.second == second;
    }

    public int hashCode() {
        return 31 * first + second;
    }

    public static void main(String[] args) {
        Set<Bigram> s = new HashSet<>();
        for (int i = 0; i < 10; i++)
            for (char ch = 'a'; ch <= 'z'; ch++)
                s.add(new Bigram(ch, ch));
        System.out.println(s.size());
    }
}

 

위와 같이 equals 메서드를 작성했을 때 HashSet 은 중복을 허용하지 않는 자료구조이기에 26 이라는 값이 출력될 것이라고 예상하지만, 260 이 출력된다. HashSet 에서 사용하는 실제 Object 클래스의 equals 메서드의 원형은 다음과 같다.

  • public boolean equals(Object o)

하지만 예제에서는 Bigram 을 인자로 받는 equals 메서드를 오버로딩 하게 되었고, Object 의 equals 메서드의 기본 형태인 == 를 통해 객체의 식별성만 확인하다 보니 260 이라는 잘못된 값이 나오게 된 것이다. 이처럼 @Override 애너테이션을 붙이지 않고 직접 작성하면 타입을 잘못 작성하는 실수를 하게 된다.

만약 @Override 라는 애너테이션을 붙이고 위와 같이 타입을 잘못 작성하게 되면 아래와 같이 컴파일 오류가 발생하기 때문에 올바르게 바로 수정할 수 있다.

 

method does not override or implement a method from a supertype

 

↓  (올바르게 수정되었을 때 모습)

 

@Override
public boolean equals(Object o) {
	if (!(o instanceof Biagram)) {
    	return false;
    }
    Biagram b = (Biagram) o;
	return b.first == first && b.second == second;
}

 

위 예제처럼 살펴보았듯이 가급적 상위 클래스의 메서드를 재정의하려는 모든 메서드에 @Override 애너네이션을 다는 것을 권장한다. 구체 클래스에서 상위 클래스의 추상 메서드를 재정의한 경우에는 애너테이션을 달지 않아도 되지만 달아도 해로울 것은 없다. (구체 클래스인데 아직 구현하지 않은 추상 메서드가 남아 있다면 컴파일러가 바로 그 사실을 알려주긴 한다.)

 

"재정의한 모든 메서드에 @Override 애너테이션을 달자"

반응형
반응형

[문제코드]

 

어떤 사람에 대한 정보를 DB 에 저장할 때 취미가 여러 개라면 아래와 같이 여러 칼럼에 나누어 저장하는 안티패턴을 생각할 수 있다.

 

CREATE TABLE Person (
	person_number INT PRIMARY KEY,
    name VARCHAR(10),
    hobby1 VARCHAR(20),
    hobby2 VARCHAR(20),
    hobby3 VARCHAR(20),
)

 

해당 구조는 아래와 같이 여러 단점을 가지고 있다.

 

1 . 검색 문제

원하는 정보가 어느 칼럼에 있는지 모르기 때문에 모든 필드들을 확인해야 한다.

hobby1, hobby2, hobby3 이 모두 null 로 초기화되어 있는 상태에서 하나씩 hobby 를 추가하기 때문에 찾으려고 하는 값이 어느 위치에 있는지 확인하기 어렵다.

 

SELECT * 
FROM Person
WHERE hobby1 = 'soccer'
   OR hobby2 = 'soccer'
   OR hobby3 = 'soccer';

 

취미가 축구인 사람을 찾기 위해 모든 다중 속성 칼럼들에 대해 찾는 것을 볼 수 있다.

 

2 . 수정 문제

마찬가지로 여러 칼럼 중 어떤 칼럼을 수정해야 할지 먼저 검색을 해야한다는 것이 단점이다. 그런데 이마저도 동시성 문제가 존재하여 둘 중 하나는 충돌로 인해 업데이트에 실패하거나 변경 내용을 덮어쓸 수 있다. 그래서 아래와 같이 NULLIF 함수를 통해 칼럼 값이 특정 값과 같으면 NULL 로 만드는 작업을 해야해서 번거롭다.

 

먼저 삭제 코드는 다음과 같다.

 

UPDATE Person 
SET hobby1 = NULLIF(hobby1, 'soccer'),
    hobby2 = NULLIF(hobby2, 'soccer'),
    hobby3 = NULLIF(hobby3, 'soccer')
WHERE person_number = '~';

 

만약에 첫 번째 NULL 인 칼럼에 추가하는 작업을 하는 코드를 만들어 본다고 생각해보자. 각 칼럼마다 NULL 이 아니면 아무런 변경도 가하지 않고 새 태그 값은 기록하지 않는 과정을 해야할 것이다.

 

3 . 일관성 문제

일관성도 문제다. 여러 칼럼에 중복되는 값이 입력될 수 있다.

 

4 . 확장 문제

취미가 하나 더 필요하다면, 해당 테이블 칼럼을 확장해야 하는데 이 때 테이블 전체를 잠금 설정하고 모든 클라이언트의 접근을 차단하는 과정이 필요하게 된다. 예전 구조의 데이터들을 마이그레이션해야하고, 그 양도 많다면 작업 시간이 많이 걸릴 수 있다. 또한 해당 테이블을 사용하는 모든 애플리케이션의 SQL 구문을 변경해야 한다.

 

[해결방법]

다중 속성 칼럼들을 종속 테이블로 변환 생성해서 사용하면 된다.

 

CREATE TABLE person (
  person_number INT PRIMARY KEY,
  name VARCHAR(10),
);

CREATE TABLE Hobby (
  person_number INT FOREIGN KEY REFERENCES person(person_number)
  hobby_name VARCHAR(10)
);

 

모든 문제를 해결할 수 있다.

1 . 검색 해결

 

SELECT * 
FROM Person JOIN Hobby USING (hobby_id)
WHERE hobby_name = 'soccer';

 

두 개의 취미를 가진 사람도 손쉽게 찾을 수 있다.

 

SELECT * FROM Person
	JOIN Hobby AS h1 USING (hobby_id)
	JOIN Hobby AS h2 USING (hobby_id)
WHERE h1.hobby_name = 'soccer' AND h2.hobby_name = 'sing';

 

2 . 수정 해결

수정 문제도 쉽게 해결 할 수 있다. 단순히 종속 테이블에 행을 추가하거나 삭제하면 된다.

 

--- 수정
INSERT INTO Hobby (member_id, hobby_name) VALUES (1, 'soccer');

--- 삭제
DELETE FROM Hobby WHERE member_id = 1 AND hobby_name = 'soccer';
반응형

'인프라 > DB' 카테고리의 다른 글

[DB 안티패턴] 대용량 데이터를 위한 테이블 분리  (0) 2023.10.29
반응형

ITEM 39 "명명 패턴보다 애너테이션을 사용하라"

 

예전 프레임워크들을 살펴보면 이름을 통해 제약을 하는 경우가 있다. 예를 들어 Junit 버전 3까지는 테스트 메서드 이름을 무조건 test 로 시작했어야 했다. 이런 경우를 명명 패턴이라고 하는데 이번 절에서는 이런 명명 패턴을 사용하는 것보다 애너테이션을 사용하도록 권장하고 있다.

 

명명 패턴을 사용하면 오타가 나면 절대 안 된다. test 로 시작해야 하는데 tset 으로 오타를 치면 Junit3 에서는 그냥 무시하고 지나가기 때문에 통과했다고 오해할 수 있다.

또한, 올바른 프로그램 요소에 사용되라라 보장할 방법이 없다. Junit3 의 테스트 단위는 메서드인데 클래스만 test 로 이름을 짓고 넘겼다고 가정해보자. 개발자는 테스트가 수행되었을 것이라고 기대했겠지만, Junit 의 테스트 대상이 아니어서 통과된다.

프로그램 요소를 매개변수로 전달할 방법이 없다는 것도 문제이다. 특정 예외를 전달해야만 성공하는 테스트가 있다고 가정해보자. 기대하는 예외 타입을 매개변수로 전달해야 하는데 명명패턴이다보니 제약할 방법이 없다. 예외 이름을 테스트 메서드 이름에 덧붙이는 방식으로 할 수도 있지만 이 방법은 보기도 나쁘고 깨지기도 쉽다.

 

애너테이션은 이런 모든 문제점을 해결해준다. 애너테이션을 활용해서 테스트 프로그램(예외가 발생하면 테스트 실패)을 작성해보자.

 

/**
 * 테스트 메서드임을 선언하는 애너테이션이다.
 * 매개변수 없는 정적 메서드 전용이다.
 */
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
	public @interface Test {
}

 

Test 애너테이션 자체에도 메타 애너테이션이 붙어있다. 각각 아래 의미를 가진다.

  • Retentaion : 해당 애너테이션이 언제까지 유지되어야 하는지 알려주는 애너테이션이다. (예제에서는 런타임까지)
    • (해당 태그가 존재하지 않으면 테스트 도구는 해당 애너테이션을 인식할 수 없다.)
  • Target : 어떤 프로그램 요소에 사용되어야 하는지 알려주는 애너테이션이다. (예제에서는 메서드에만 적용 가능)

아쉽게도 매개변수가 없다는 제약을 줄 수는 없는데 이를 컴파일러가 강제하게 하려면 javax.annotation.processing API 문서처럼 직접 구현해야 한다. 구현하지 않는다면 컴파일은 잘 되겠지만, 테스트 할 때 문제가 생긴다.

 

이렇게 구현해두면 마킹을 하듯이 애너테이션을 붙일 수 있어 원래 클래스에 직접적인 영향을 주지 않으면서 테스트할 범위를 정할 수 있다는 장점이 있다. 해당 애너테이션을 사용하는 구현부를 작성해보자.

 

public class RunTests {
	public static void main(String[] args) throws Exception {
        int tests = 0;
        int passed = 0;
        Class<?> testClass = Class.forName(args[0]);
        for (Method m : testClass.getDeclaredMethods()) {
            if (m.isAnnotationPresent(Test.class)) {
                tests++;
                try {
                    m.invoke(null);
                    passed++;
                } catch (InvocationTargetException wrappedExc) {
                    Throwable exc = wrappedExc.getCause();
                    System.out.println(m + " 실패: " + exc);
                } catch (Exception exc) {
                    System.out.println("잘못 사용한 @Test: " + m);
                }
            }
        }
        System.out.printf("성공: %d, 실패: %d%n",
                passed, tests - passed);
    }
}

 

 

리플렉션과 클래스 이름을 활용해서, @Test 애너테이션이 달린 메서드를 차례로 호출하는 것을 볼 수 있다. 테스트 메서드가 예외를 던지면, 리플렉션 메커니즘이 InvocationTargetException 으로 감싸서 예외를 다시 던진다. 이 프로그램은 해당 예외를 잡아 실패 정보를 추출해 출력한다. InvocationTargetException 외 다른 예외가 발생했다면 @Test 애너테이션을 잘못 사용한 것이다.

 

1) 특정 예외를 인자로 전달

다음은 특정 예외를 인자로 전달하여 처리하는 애너테이션 처리기를 만들어보자.

 

/**
 * 명시한 예외를 던져야만 성공하는 테스트 메서드용 애너테이션
 */
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
public @interface ExceptionTest {
    Class<? extends Throwable> value();
}

 

 

Class<? extends Throwable> 타입으로 인자를 받기 때문에 모든 예외와 오류 타입을 수용할 수 있다. 사용부는 다음과 같다.

 

public class Sample2 {
    @ExceptionTest(ArithmeticException.class)
    public static void m1() {  // 성공해야 한다.
        int i = 0;
        i = i / i;
    }
    @ExceptionTest(ArithmeticException.class)
    public static void m2() {  // 실패해야 한다. (다른 예외 발생)
        int[] a = new int[0];
        int i = a[1];
    }
    @ExceptionTest(ArithmeticException.class)
    public static void m3() { }  // 실패해야 한다. (예외가 발생하지 않음)
}

 

public class RunTests {
    public static void main(String[] args) throws Exception {
        int tests = 0;
        int passed = 0;
        Class<?> testClass = Class.forName(args[0]);
        for (Method m : testClass.getDeclaredMethods()) {
            if (m.isAnnotationPresent(Test.class)) {
                tests++;
                try {
                    m.invoke(null);
                    passed++;
                } catch (InvocationTargetException wrappedExc) {
                    Throwable exc = wrappedExc.getCause();
                    System.out.println(m + " 실패: " + exc);
                } catch (Exception exc) {
                    System.out.println("잘못 사용한 @Test: " + m);
                }
            }

            if (m.isAnnotationPresent(ExceptionTest.class)) {
                tests++;
                try {
                    m.invoke(null);
                    System.out.printf("테스트 %s 실패: 예외를 던지지 않음%n", m);
                } catch (InvocationTargetException wrappedEx) {
                    Throwable exc = wrappedEx.getCause();
                    Class<? extends Throwable> excType =
                            m.getAnnotation(ExceptionTest.class).value();
                    if (excType.isInstance(exc)) {
                        passed++;
                    } else {
                        System.out.printf(
                                "테스트 %s 실패: 기대한 예외 %s, 발생한 예외 %s%n",
                                m, excType.getName(), exc);
                    }
                } catch (Exception exc) {
                    System.out.println("잘못 사용한 @ExceptionTest: " + m);
                }
            }
        }

        System.out.printf("성공: %d, 실패: %d%n",
                passed, tests - passed);
    }
}

 

2) 여러 개의 예외를 인자로 전달

더 나아가 여러 개의 예외를 인자로 전달해서 그 중 하나가 발생하면 성공하게 만들 수도 있다.

 

/**
 * 배열 매개변수를 받는 애너테이션
 */
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
public @interface ExceptionTest {
    Class<? extends Throwable>[] value();
}

 

[] 배열이 추가되었다. Class 타입들을 배열로 인자를 받아 처리하는데 기존 한 개 매개변수도 처리할 수 있다는 장점이 있다. 수정없이 사용 가능하다. 원소가 여럿인 배열들을 지정할 때 아래와 같이 원소들을 중괄호로 감싸고 쉼표로 구분하면 된다.

 

@ExceptionTest({IndexOutOfBoundsException.class, NullPointerException.class})
public static void doublyBad() {   // 성공해야 한다.
    List<String> list = new ArrayList<>();

    // 자바 API 명세에 따르면 다음 메서드는 IndexOutOfBoundsException이나
    // NullPointerException을 던질 수 있다.
    list.addAll(5, null);
}

 

(... 생략 동일 ...)
int oldPassed = passed;
Class<? extends Throwable>[] excTypes =
	m.getAnnotation(ExceptionTest.class).value();
for (Class<? extends Throwable> excType : excTypes) {
    if (excType.isInstance(exc)) {
        passed++;
        break;
    }
}  
if (passed == oldPassed) {
    System.out.printf("테스트 %s 실패: %s %n", m, exc);
}
(... 생략 동일 ...)

 

3) (자바 8 방식)여러 개의 예외를 인자로 전달

자바 8 부터는 위 방식 대신 @Repeatable 메타애너테이션을 사용하여 하나의 프로그램 요소에 여러 번 달 수 있다.

단 주의할 점이 있다. 아래 3가지 주의사항을 만족하지 않는다면 컴파일되지 않는다. 

첫 째. @Repeatable 을 단 애너테이션을 반환하는 '컨테이너 애너테이션'을 하나 더 정의하고, @Repeatable 에 이 컨테이너 애너테이션의 Class 객체를 매개변수로 전달해야 한다.

둘 째. 컨테이너 애너테이션은 내부 애너테이션 타입의 배열을 반환하는 value 메서드를 정의해야 한다.

셋 째. @Retentioin 과 @Target 메타 애너테이션을 명시해야 한다.

 

@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
@Repeatable(ExceptionTestContainer.class)
public @interface ExceptionTest {
    Class<? extends Throwable> value();
}

// 컨테이너 애너테이션
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
public @interface ExceptionTestContainer {
		ExceptionTest[] value();
}

 

처리할 때도 주의가 필요하다. getAnnotaionByType 메서드는 컨테이너 애너테이션과 반복 가능 어노테이션을 구분하지 못 하는데, isAnnotationPresent 메서드는 명확하게 구분한다. 하지만 여러 번 달린 애너테이션은 구분하기가 힘들어 컨터이너 쪽과 반복 가능 어노테이션 쪽 모두를 검증해야 한다.

 

if (m.isAnnotationPresent(ExceptionTest.class) || m.isAnnotationPresent(ExceptionTestContainer.class))
{
	...
}

 

 

"소스코드에 추가정보를 제공해야 한다면 애너테이션을 고려해보자"

반응형
반응형

ITEM 38 "확장할 수 있는 열거타입이 필요하면 인터페이스를 사용하라"

 

enum 열거 타입은 확장할 수 없다. 확장이 가능하다면 기반이 되는 타입과 확장이 되는 타입들의 원소 모두를 순회하는 방법이 있어야 하는데 방법이 마땅치 않다.

 

그런데도 확장이 가능한 enum 타입이 필요할 때가 있는데 대표적인 예가 연산코드이다. 연산코드 같이 명령어들을 열거타입으로 만들 때 보통 사용자가 확장 연산을 추가할 수 있도록 열어주기 때문에 확장이 용이해야 한다.

 

다행히 enum 타입이 인터페이스를 구현할 수 있어, 행위 자체를 인터페이스에 명명하고 그 인터페이스를 구현하면 된다.

 

public interface Operation {
	double apply(double x, double y);
}

public enum BasicOperation implements Operation {
	PLUS("+") {
        @Override
        public double apply(double x, double y) {
            return x + y;
        }
    },
    MINUS("-") {
        @Override
        public double apply(double x, double y) {
            return x - y;
        }
    },
    TIMES("*") {
        @Override
        public double apply(double x, double y) {
            return x * y;
        }
    },
    DIVIDE("/") {
        @Override
        public double apply(double x, double y) {
            return x / y;
        }
    };

    private final String symbol;

    BasicOperation(String symbol) {
        this.symbol = symbol;
    }

    @Override
    public String toString() {
        return symbol;
    }
}

 

위와 같이 구현하면 BasicOperation Enum 자체는 확장할 수 없지만, 인터페이스인 Operation 은 확장할 수 있기 때문에, 이 인터페이스를 타입으로 사용하면 된다. 사칙연산에 이어서 지수 곱, 나머지 연산자를 추가하고 싶다면 아래와 같이 만들 수 있다.

 

public enum ExtendedOperation implements Operation {
    EXP("^") {
        @Override
        public double apply(double x, double y) {
            return Math.pow(x, y);
        }
    },
    REMAINDER("%") {
        @Override
        public double apply(double x, double y) {
            return x % y;
        }
    };

    private final String symbol;

    ExtendedOperation(String symbol) {
        this.symbol = symbol;
    }

    @Override
    public String toString() {
        return symbol;
    }
}

 

사용부에서 인터페이스를 사용하도록 작성되어 있다면, BasicOperation Enum 을 ExtendedOperation Enum 으로 교체할 수도 있다. 이미 인터페이스 내부에 메서드들이 명명되어 있어 열거 타입에 따로 추상 메서드를 선언하지 않아도 된다. (Enum 상수별 메서드 구현)

 

사용할 때는 아래 두 가지 방법이 존재한다.

 

1) 클래스 타입을 인자로 전달

 

public enum ExtendedOperation implements Operation {
    public static void main(String[] args) {
        double x = Double.parseDouble(args[0]);
        double y = Double.parseDouble(args[1]);
        test(ExtendedOperation.class, x, y);
    }
    private static <T extends Enum<T> & Operation> void test(Class<T> opEnumType, double x, double y) {
        for (Operation operation : opEnumType.getEnumConstants()) {
            System.out.printf("%f %s %f = %f%n", x, operation, y, operation.apply(x, y));
        }
    }
}

 

한정적 타입 토큰 역할을 하는 Class 리터럴을 전달하고, 그 리터럴의 getEnumConstants() 함수를 사용해서 접근한다.

Class 객체가 열거 타입인 동시에 Operation 의 하위 타입어야 하기 때문에 함수의 타입은 <T extends Enum<T> & Operation> 이어야 한다. 열거 타입이어야 원소를 순회할 수 있고, Operation 이어야 원소가 뜻하는 연산을 할 수 있기 때문이다.

 

2) 한정적 와일드카드 타입을 인자로 전달

 

public enum ExtendedOperation implements Operation {
    public static void main(String[] args) {
        double x = Double.parseDouble(args[0]);
        double y = Double.parseDouble(args[1]);
        test(Arrays.asList(ExtendedOperation.values()), x, y);
    }
    private static void test(Collection<? extends Operation> operations, double x, double y) {
        for (Operation operation : operations) {
            System.out.printf("%f %s %f = %f%n", x, operation, y, operation.apply(x, y));
        }
    }
}

 

enum 타입의 값들을 컬렉션 형태로 전달하면 여러 구현 타입의 연산을 조합해 호출할 수 있다는 장점이 있다.

다만 특정 연산(EnumSet, EnumMap) 은 사용하지 못 한다.

 

인터페이스를 이용해 확장 가능한 열거 타입을 흉내내는 이 방식에도 아래와 같은 문제점이 존재한다.

  • 열거 타입끼리는 구현을 상속할 수 없다.
  • 아무 상태에도 의존하지 않는 경우라면, 인터페이스를 구현한 enum 타입들에 직접 로직들을 작성해야 한다. (중복되는 로직이 많아진다면 정적 도우미 메서드나 클래스로 분리해야 한다.)

 

"enum 열거 타입 자체는 확장할 수 없지만, 인터페이스와 그 인터페이스를 구현한

기본 열거 타입을 함께 사용해 같은 효과를 낼 수 있다."

반응형
반응형

React Hook 에 대해 공부하다보면, 아래와 같은 문구를 만나게 된다.

 

 

막연하게 Hook 의 실행 순서와 선언 범위에 대한 규칙만 서술되어 있고, 왜 하지 말아야 하는지에 대한 내용은 서술되어 있지 않다. Hook 은 클로저와 아주 밀접한 관련이 있는데 클로저의 특징 때문에 생긴 제약사항이다.

 

외부에서 변수 접근을 하지 못 하게 만들고, 함수 자체를 리턴하게 하고 싶을 때, 아래와 같이 생각해볼 수 있다.

 

function getAdd() {
  let foo = 1
  return function () {
    foo += 1
    return foo
  }
}

 

함수 자체를 리턴하고 함수 내에서 사용하는 변수를 내부에 선언한다. 이렇게 구현하면 getAdd 를 통해 생성된 함수 객체를 호출할 때마다 내부에 선언되어 있는 변수에 계산을 할 수 있게 된다. 클로저다.

 

이 함수를 모듈 패턴을 통해 아래와 같이 구성할 수도 있다.

 

const add = (function getAdd() {
  let foo = 1;
  return function () {
    foo += 1;
    return foo;
  }
})()

 

사실 함수를 반환하면서 내부에 있는 변수들을 외부에서 제어하고자 한다면 고차함수들 대부분이 클로저일 수 밖에 없다.

React Hook 들도 모두 클로저이다.

 

const React = (function () {
  function useState(initVal) {
    let _val = initVal
    const state = () => _val
    const setState = (newVal) => {
      _val = newVal
    }
    return [state, setState]
  }
  return { useState }
})()

const [count, setCount] = React.useState(1)
console.log(count()) // 1
setCount(2)
console.log(count()) // 2

 

useState 를 구현해보면 위와 같다. 초기값을 인자로 받아 내부 변수에 세팅하고, 그 변수를 활용한 함수들을 리턴하는 것을 볼 수 있다. 내부 상태 값을 반환하는 함수와 갱신하는 함수를 배열 형태로 리턴하면 React 에서 흔히 사용하는 useState 와 유사하다.

 

하지만 hook 을 여러 번 사용한다면 위와 같은 구조는 정상적으로 동작하지 않게 된다. 위 React 모듈에 있는 값은 _val 하나이기 때문이다. 갱신하는 함수를 호출할 때마다 _val 변수가 덮어씌워져 호출이 되지 않는 것이다.

 

이를 방지하기 위해 배열에 hook 함수들을 배치하고 인덱스를 활용하여 사용해 접근하도록 개선했다.

 

const React = (function () {
  let hooks = []
  let idx = 0
  function useState(initVal) {
    const state = hooks[idx] || initVal
    const setState = (newVal) => {
      hooks[idx] = newVal
    }
    idx += 1
    return [state, setState]
  }
  function render(Component) {
    const C = Component()
    C.render()
    console.log(idx) // 2, 4, 6
    return C
  }
  return { useState, render }
})()

function Component() {
  const [count, setCount] = React.useState(1)
  const [text, setText] = React.useState('apple')

  return {
    render: () => console.log({ count, text }),
    click: () => setCount(count + 1),
    type: (word) => setText(word),
  }
}

var App = React.render(Component) // { count: 1, text: 'apple' }
App.click()
var App = React.render(Component) // { count: 2, text: 'apple' }
App.type('pear')
var App = React.render(Component) // { count: 'pear', text: 'apple' }

 

갱신이 정상적으로 되는 것 같지만, React.render 를 하는 순간 계속해서 idx 를 추가하게 되어서 이상하게 값이 수정되는 것을 볼 수 있다. 이미 증가된 idx 를 통해 갱신을 하는 것이 문제점이 되어 버렸다.

 

문제점을 해결하기 위해 아래와 같이 수정한다.

render 함수 내부에 idx 값을 초기화하고, 각 모듈 내의 함수에서 사용할 index 를 고정해서 공급해주면 여러 hook 들이 각자 고유한 index 를 가지게 되어 그에 맞는 상태를 변경할 수 있게 된다.

 

const React = (function () {
  let hooks = []
  let idx = 0
  function useState(initVal) {
  	const state = hooks[idx] || initVal
  	const _idx = idx // freeze
  	const setState = (newVal) => {
    	hooks[_idx] = newVal
  	}
    idx += 1
    return [state, setState]
  }

  function render(Component) {
  	idx = 0
    const C = Component()
    C.render()
    return C
  }
  return { useState, render }
})()

function Component() {
  const [count, setCount] = React.useState(1)
  const [text, setText] = React.useState('apple')

  return {
    render: () => console.log({ count, text }),
    click: () => setCount(count + 1),
    type: (word) => setText(word),
  }
}

var App = React.render(Component) // { count: 1, text: 'apple' }
App.click()
var App = React.render(Component) // { count: 2, text: 'apple' }
App.type('pear')
var App = React.render(Component) // { count: 2, text: 'peer' }

 

[출처]

본 포스팅은 (2019년 JS 컨퍼런스에서 발표한) React Hooks 와 Closure 의 관계를 잘 설명한 동영상을 재해석하였다.

 

반응형
반응형

모든 리소스를 한 번에 로드하는 것이 아니라 사용자가 보는 뷰포트 영역에 접근했을 때 지연로딩하고 싶을 때가 있다.

그 때 사용할 수 있는 API 가 IntersectionObserver API 이다. 뷰포트 영역의 특정 교차점 부분을 진입했을 때 새로운 액션을 할 수 있다.

 

IntersectionObserver() - Web API | MDN (mozilla.org)

 

IntersectionObserver() - Web API | MDN

IntersectionObserver() 생성자는 새로운 IntersectionObserver 객체를 생성하고 반환합니다.

developer.mozilla.org

 

InsersectionObserver 생성자를 살펴보면 첫 번째 인자에는 두 번째 인자(대상 요소의 가시성 비율) 값보다 역치 값이 넘어갈 경우 감시할 옵저버 함수를 만들 수 있다.

 

특정 비율이 넘어갈 때 사진을 로드하는 함수를 만든다고 생각해보자.

 

감시할 imageRef 대상이 있는데 imageSrc 이미지 경로가 없다면, 그 때 생성자를 생성하여 감시를 시작한다.

감시하다가 교차지점에 도달하면 imageSrc 이미지 경로를 세팅한다.

 

import { useState, useRef, useEffect } from "react";

export function useLazyImageObserver({ src }) {
    const [imageSrc, setImageSrc] = useState(null);
    const imageRef = useRef(null);

    useEffect(()=>{
        let observer;
        if (imageRef && !imageSrc) {
            observer = new IntersectionObserver(([entry]) => {
                if (entry.isIntersecting) {
                    setImageSrc(src);
                    observer.unobserve(imageRef.current);
                }
            }, { threshold: [0.25] });
            observer.observe(imageRef.current);
        }
        return () => {
            observer && observer.disconnect(imageRef);
        };
    }, [imageRef, imageSrc, src]);

    return { imageSrc, imageRef };
}

 

[사용부]

import { memo } from "react";
import { useLazyImageObserver } from "../hooks/useLazyImageObserver";

export const LazyImage = memo(({src, alt})=>{
    const { imageSrc, imageRef } = useLazyImageObserver({ src });
    return (
        <img ref={imageRef} src={imageSrc} alt={alt} width='200px' height='300px'/>
    );
});

 

const urlList = [
  'https://picsum.photos/200/300?random=1',
  'https://picsum.photos/200/300?random=2',
  'https://picsum.photos/200/300?random=3',
  'https://picsum.photos/200/300?random=4',
  'https://picsum.photos/200/300?random=5',
  'https://picsum.photos/200/300?random=6',
  'https://picsum.photos/200/300?random=7',
  'https://picsum.photos/200/300?random=8',
  'https://picsum.photos/200/300?random=9',
  'https://picsum.photos/200/300?random=10',
  'https://picsum.photos/200/300?random=11',
  'https://picsum.photos/200/300?random=12',
  'https://picsum.photos/200/300?random=13',
  'https://picsum.photos/200/300?random=14',
  'https://picsum.photos/200/300?random=15',
  'https://picsum.photos/200/300?random=16',
  'https://picsum.photos/200/300?random=17',
  'https://picsum.photos/200/300?random=18',
  'https://picsum.photos/200/300?random=19',
  'https://picsum.photos/200/300?random=20',
  'https://picsum.photos/200/300?random=21',
  'https://picsum.photos/200/300?random=22',
  'https://picsum.photos/200/300?random=23',
  'https://picsum.photos/200/300?random=24',
  'https://picsum.photos/200/300?random=25',
  'https://picsum.photos/200/300?random=26',
  'https://picsum.photos/200/300?random=27',
  'https://picsum.photos/200/300?random=28',
  'https://picsum.photos/200/300?random=29',
  'https://picsum.photos/200/300?random=30',
  'https://picsum.photos/200/300?random=31',
  'https://picsum.photos/200/300?random=32',
  'https://picsum.photos/200/300?random=33',
  'https://picsum.photos/200/300?random=34',
  'https://picsum.photos/200/300?random=35',
  'https://picsum.photos/200/300?random=36',
];

function App() {
  
  return (
    <div style={{ width: '600px' }}>
      {urlList && urlList.map((url, index) => (
          <LazyImage key={index} src={url} alt=""/>
      ))}
    </div>
  );
}

 

반응형
반응형

특정 기간동안 함수가 너무 많이 호출될 경우, 함수 실행을 건너뛸 수 있는 debounce 나 throttle 기법을 고려할 수 있다.

그 중 debounce 는 이벤트를 그룹화하여 많은 이벤트가 발생해도 모두 무시하고, 하나의 이벤트만 실행시키도록 하는 기법이다. lodash 라이브러리에서 지원한다.

 

input 태그에서 사용자가 입력을 수시로 변할 때, 백엔드에 input 데이터를 보내는 예제를 생각해보자.

 

const Input = () => {
  const [value, setValue] = useState();

  const sendRequest = (value) => {
    // 백엔드에 input 데이터를 보냄
  };

  const debouncedSendRequest = debounce(sendRequest, 500);

  const onChange = (e) => {
    const value = e.target.value;
    setValue(value);
    debouncedSendRequest(value);
  }

  return <input onChange={onChange} value={value} />
}

 

위와 같이 코드를 생각해볼 수 있다. onChange 함수에서 value 를 세팅하고 debounced 된 함수를 호출하는 형태이다.

하지만 위 코드는  value 값이 바뀔 때마다 리렌더링이 되면서 아래와 같은 문제점이 생긴다.

 

  • sendRequest, debouncedSendRequest 함수가 계속 파괴되고 생성된다.
  • timer 로 인해 바로 파괴되는 것이 아니라 timer 시간 동안 유지되었다가 참조하는 곳이 없어서 가비지 컬렉션에 의해 정리된다.

 

const Input = () => {
  const [value, setValue] = useState("initial");

  const sendRequest = useCallback((value) => {
    // 백엔드에 input 데이터를 보냄
  }, []);

  const debouncedSendRequest = useMemo(() => {
  	return debounce(sendRequest, 500);
  }, [sendRequest]);

  const onChange = (e) => {
    const value = e.target.value;
    setValue(value);
    debouncedSendRequest(value);
  }

  return <input onChange={onChange} value={value} />
}

 

리렌더링이 되어서 함수가 파괴가 되어 참조를 못 하는 문제를 해결하기 위해 sendRequest 함수 자체에는 useCallback 훅으로 감싸고, debouncedSendRequest 함수는 useMemo 훅으로 감싸서 코드를 수정해보았다. 정상적으로 동작하는 것처럼 보인다.

 

debouncedSendRequest(value) 구문보면 계속해서 value 인자를 주고 있는데 이 부분을 useCallback 의 종속성 인자로 바꾸어 보면, 결국 처음 코드와 같게 된다.

 

리바운싱할 때마다 디바운스 함수를 생성하지 않게 하는 것이 관건인데 보통 useRef 훅으로 함수를 감싸는 것을 추천한다. 아래와 같은 형태가 되는데 만약 useRef 내부 함수에서 state 값을 참조하면 클로저가 되어버리기 때문에 주의해야 한다.

 

const ref = useRef(debounce(() => {
    // value 가 scope 를 벗어난 외부 변수이기 때문에 초기값으로 세팅된다 (클로저)
    console.log(value);
}, 500));

 

value 상태 값을 항상 최신으로 유지하기 위해 함수를 다시 호출하고 ref 에 다시 할당해야 한다.

(즉, 클로저라는 문제 때문에 value 값이 변경될 때마다, ref.current 값을 교체해주어야 된다.)

 

useEffect(() => {
    ref.current = debounce(() => {
    }, 500);
}, [value]);

 

또 처음 코드와 같게 된다. 클로저로 변형된 함수들을 Ref 로 묶고, debouncedCallback 함수만 useMemo  로 결과 값만 재 사용한다면 위 useMemo 와 useCallback 의 이점을 동시에 사용할 수 있게 된다.

 

[최종 코드]

  const [value, setValue] = useState("initial");
  const ref = useRef();

  const onChange = () => {
  };

  useEffect(() => {
    ref.current = onChange;
  }, [onChange]);

  const debouncedCallback = useMemo(() => {
    const func = () => {
      ref.current?.();
    };
    return debounce(func, 1000);
  }, []);

 

[출처]

How to debounce and throttle in React without losing your mind (developerway.com)

반응형
반응형

ITEM 37 "ordinal 인덱싱 대신 EnumMap 을 사용하라"

 

enum 열거 타입을 기준으로 집합을 만들고 싶을 때, enum 을 키 값으로 하는 EnumMap 을 사용하는 것을 권장한다.

enum 의 ordinal 함수의 결과 값을 배열의 인덱스로 사용해서 분류하는 것보다 훨씬 안정적이고 간결하다.

 

static class Plant {
    enum LifeCycle { ANNUAL, PERENNIAL, BIENNIAL }
    final String name;
    final LifeCycle lifeCycle;

    public Plant(String name, LifeCycle lifeCycle) {
        this.name = name;
        this.lifeCycle = lifeCycle;
    }

    @Override
    public String toString() {
        return name;
    }
}

 

위와 같이 식물에 대한 클래스가 주어질 때, ANNUAL(한해살이), PERENNIAL(여러해살이), BIENNIAL(두해살이) 를 기준으로 식물들을 분류하고 싶다고 가정하자. 두 가지 방식을 떠올릴 수 있다.

 

  • Enum 열거체를 순서대로 표현한 이중 배열
  • Enum 열거체를 키 값으로 하는 Map

 

Enum 열거체를 순서대로 표현한 이중 배열은 문제점이 많다. Enum 의 길이는 고정되어 있으므로 배열로 만들텐데 배열은 일전에도 언급하였듯이 제네릭과 호환이 되지 않는다. (비검사 형변환을 해야될 수도 있다.) 또한, 배열은 각 인덱스의 의미를 모르니 출력할 때 레이블을 달아주어야 한다. 아마도 이렇게 구현할 것이다.

 

Set<Plant>[] plantsByLifeCycle = (Set<Plant>[]) new Set[Plant.LifeCycle.values().length];
for (int i = 0; i < plantsByLifeCycle.length; i++) {
    plantsByLifeCycle[i] = new HashSet<>();
}

// ordinal 로 enum 인덱스를 구해서 배열에 hashset 을 삽입
for (Plant p : garden) {
    plantsByLifeCycle[p.lifeCycle.ordinal()].add(p);
}

// 배열의 인덱스가 무엇을 뜻하는지 몰라서 values 를 다시 한 번 호출하는 모습
for (int i = 0; i < plantsByLifeCycle.length; i++) {
    System.out.printf("%s: %s%n", Plant.LifeCycle.values()[i], plantsByLifeCycle[i]);
}

 

Enum 열거체를 키 값으로 하는 EnumMap 을 사용하면 Enum 열거체가 변경이 되어도 유연하게 적용할 수 있고 안전하다.

안전하지 않은 형변환도 사용하지 않으면서 열거타입 자체에서 toString 출력용 문자열을 제공하니 출력 결과에 레이블을 달 필요도 없다. 더 나아가 배열 인덱스를 계산하는 과정에서 오류가 날 가능성도 완전봉쇄한다.

 

Map<Plant.LifeCycle, Set<Plant>> plantsByLifeCycle = new EnumMap<>(Plant.LifeCycle.class);
for (Plant.LifeCycle lc : Plant.LifeCycle.values()) {
    plantsByLifeCycle.put(new HashSet<>());
}
for (Plant p : garden) {
    plantsByLifeCycle.get(p.lifeCycle).add(p);
}
System.out.println(plantsByLifeCycle);

 

(Strem 버전)

 

 System.out.println(garden.stream()
 	.collect(Collectors.groupingBy(p -> p.lifeCycle,
    	() -> new EnumMap<>(Plant.LifeCycle.class), Collectors.toSet())));

 

이중으로 Enum 열거체의 값들을 매핑할 때도, EnumMap 으로 관리하면 좋다.

 

public enum Phase {
    SOLID, LIQUID, GAS;
    public enum Transition {
        MELT(SOLID, LIQUID), FREEZE(LIQUID, SOLID),
        BOIL(LIQUID, GAS), CONDENSE(GAS, LIQUID),
        SUBLIME(SOLID, GAS), DEPOSIT(GAS, SOLID);

        private final Phase from;
        private final Phase to;
        Transition(Phase from, Phase to) {
            this.from = from;
            this.to = to;
        }

        private static final Map<Phase, Map<Phase, Transition>>
            m = Stream.of(values()).collect(groupingBy(t -> t.from,
                () -> new EnumMap<>(Phase.class),
                toMap(t -> t.to, t -> t,
                (x, y) -> y, () -> new EnumMap<>(Phase.class))));

        public static Transition from(Phase from, Phase to) {
            return m.get(from).get(to);
        }
    }
 }

 

"enum 열거 타입을 기준으로 집합을 만들고 싶을 때,

배열로 만들지 않고(배열의 인덱스를 얻기 위해 ordinal() 함수 사용)

EnumMap 을 사용하자."

반응형

+ Recent posts