Devin.KR

소프트웨어 공학 · 심화

코드 리뷰 실무 - 버그를 잡고 팀을 키우는 읽기

성능 리뷰 - 측정 없이 단정하지 않기

반복 안의 조회(N+1 형태), 불필요한 객체 생성, 자료구조 선택, 측정으로 확인하는 습관, 완성 코드는 리스트 검색과 맵 검색의 연산 횟수를 세어 입력 크기별로 비교한다

개발자KR · 원고 갱신

이 장에서 배우는 것

도서관 대출 목록 화면이 느리다는 제보가 들어왔다. 리뷰 대상 코드는 대출 한 건마다 도서 목록을 검색하고, 화면에 표시할 문구를 만드는 객체도 새로 생성한다. 반복문이 있다는 이유만으로 문제가 있다고 말할 수는 없다. 입력이 얼마나 큰지, 반복 안에서 무엇을 하는지, 같은 결과를 더 적은 작업으로 만들 수 있는지부터 확인해야 한다.

앞 장에서 입력과 권한의 경계를 살폈다면, 여기서는 작업이 되풀이되는 경계를 살핀다. 성능 리뷰의 목적은 짧거나 복잡한 코드를 만드는 데 있지 않다. 기존 동작을 유지하면서 불필요한 작업을 줄이고, 그 판단을 확인할 근거를 남기는 데 있다.

  • 반복 안의 조회를 찾아 전체 작업량이 커지는 이유를 설명한다.
  • 불필요한 객체 생성과 결과를 만드는 데 필요한 객체 생성을 구분한다.
  • 검색 방식과 결과의 의미를 함께 고려해 리스트와 맵을 선택한다.
  • 수행 시간과 연산 횟수의 차이를 이해하고, 입력 크기를 바꾸어 개선 효과를 확인한다.
  • 고치기 전후에 같은 입력이 같은 결과를 만드는지 실행으로 확인한다.

문제 상황

팀은 회원의 대출 내역에 도서 제목을 붙이는 기능을 변경하고 있다. 대출 정보에는 도서 번호만 있으므로, 표시 문구를 만들려면 도서 목록에서 제목을 찾아야 한다. 현재 코드는 대출 목록을 순회하면서 매번 도서 목록의 처음부터 번호를 비교한다. 도서 정보가 없으면 정해진 대체 문구를 표시한다.

개발용 데이터에서는 대출 네 건과 도서 네 권만 사용했다. 이 정도에서는 화면이 곧바로 나타나므로 검색 비용을 알아차리기 어렵다. 그러나 여러 회원의 대출 내역을 모아 보여 주는 화면에 같은 코드를 사용하면 입력 크기가 달라진다. 리뷰어는 작은 예제로 확인한 체감 속도를 큰 입력에도 적용하지 않아야 한다.

변경 작성자는 “맵으로 바꾸면 빨라질 것 같다”는 설명을 남겼다. 방향은 타당할 수 있지만, 리뷰에는 확인할 항목이 더 있다. 맵을 만드는 비용이 포함되어 있는지, 같은 도서 번호가 여러 번 있을 때 어떤 제목을 선택하는지, 대출 목록의 순서가 유지되는지를 살펴야 한다.

대출마다 도서 목록을 처음부터 검색하고 있다. 대출 수와 도서 수가 함께 늘어날 때 번호 비교가 얼마나 증가하는지 세어 보면 좋겠다. 번호별 맵을 한 번 만든 뒤 조회하는 안과 비교하되, 결과 목록과 누락 도서의 표시가 같은지도 확인해 달라.

이 의견은 “이중 반복문을 없애자”는 지시보다 구체적이다. 의심하는 작업, 비교할 대안, 유지해야 할 동작을 드러낸다. 작성자는 실행 시간을 임의로 주장하는 대신, 먼저 검색 과정의 연산 횟수를 세는 작은 프로그램을 작성한다.

반복 안의 조회는 전체 횟수로 읽는다

대출 수를 L, 도서 수를 B라고 하자. 리스트 검색은 대출 한 건에 대해 도서 번호를 최대 B번 비교한다. 원하는 도서가 목록 뒤에 있거나 목록에 없으면 앞에서부터 거의 전부 살펴야 한다. 이 작업을 대출마다 반복하므로 번호 비교가 최대 L × B번 발생한다.

완성 코드의 기본 입력에서는 도서 번호가 1부터 n까지 순서대로 있고, 대출도 같은 순서로 한 건씩 있다. 첫 검색은 한 번, 두 번째 검색은 두 번, 마지막 검색은 n번 비교한다. 따라서 전체 비교 횟수는 n × (n + 1) / 2다. 모든 검색이 끝까지 진행하는 경우보다는 적지만, n이 커지면 비교 횟수가 빠르게 늘어난다는 점은 같다.

대출마다 리스트를 다시 검색하면 앞에서 확인한 도서도 반복해서 비교한다

저장소를 사용하는 코드에서도 비슷한 형태를 만난다. 대출 목록을 한 번 조회하고, 각 대출의 도서를 다시 한 번씩 조회한다면 대출이 N건일 때 조회 호출은 1 + N번이다. 이런 호출 구조를 N+1 형태라고 부른다. 호출 한 번이 간단해 보여도 전체 요청에서는 호출 비용이 누적된다.

여기서 사용하는 프로그램에는 저장소나 네트워크가 없다. 따라서 실제 저장소의 N+1 조회가 발생하는 예제는 아니다. 대신 반복 안에서 같은 검색 범위를 다시 훑는 구조를 메모리 안의 리스트로 관찰한다. 저장소 호출 수와 리스트의 번호 비교 수는 다른 단위이므로 같은 수치처럼 해석해서는 안 된다.

실무 리뷰에서는 메서드 이름만 보고 조회 비용을 판단하지 않는다. findBook이라는 메서드가 이미 메모리에 있는 맵을 읽을 수도 있고, 매번 외부 저장소에 접근할 수도 있다. 호출부에서 반복을 찾은 다음, 메서드 구현이나 실행 기록을 따라가 실제로 반복되는 작업을 확인한다.

반복 안의 조회를 검토할 때 확인할 근거
관찰한 코드확인할 내용비교할 대안
대출마다 리스트 검색번호 비교 횟수와 도서 수번호별 맵을 한 번 구성
대출마다 저장소 호출실제 호출 횟수와 대기 시간필요한 도서를 묶어서 조회
반복마다 보조 객체 생성객체가 반복별 상태를 갖는지호출 안에서 한 번 생성해 사용
반복마다 맵 생성같은 입력으로 다시 만드는지반복에 들어가기 전에 구성

자료구조와 객체 수명을 함께 고른다

리스트는 순서대로 처리하거나 위치로 접근할 때 자연스럽다. 반면 특정 번호에 해당하는 값을 여러 번 찾는 작업에는 맵이 어울린다. 도서 번호를 키로, 도서 정보를 값으로 저장하면 대출을 처리할 때 번호로 조회할 수 있다. 이때 해시 맵(HashMap)의 조회는 일반적인 조건에서 평균적으로 일정한 시간에 가까운 비용을 기대한다. 모든 상황에서 내부 작업이 한 번이라는 뜻은 아니다.

맵을 만들려면 도서 목록을 한 번 읽고 추가 메모리도 사용한다. 도서가 아주 많고 대출은 한 건뿐이라면, 맵 전체를 만드는 작업이 이득인지 별도로 따져야 한다. 반대로 같은 도서 집합에서 조회가 반복된다면 구성 비용을 여러 조회에 나누어 부담할 수 있다. 입력 크기뿐 아니라 한 번 만든 구조를 얼마나 사용하는지도 선택의 근거다.

자료구조를 바꾸면 중복 값의 처리 방식도 확인해야 한다. 기존 리스트 검색은 같은 번호가 여러 번 있으면 처음 만난 도서를 선택한다. 맵에 put으로 계속 넣으면 나중 도서가 앞의 값을 덮어쓴다. 완성 코드는 putIfAbsent를 사용해 처음 도서를 유지한다. 도서 객체 자체는 null이 아니라는 입력 전제도 둔다.

결과 순서는 도서를 저장한 맵의 순회 순서에 맡기지 않는다. 기존과 같이 대출 목록을 순회하면서 결과를 추가한다. 이렇게 하면 도서 검색 방식이 달라져도 대출 표시 순서는 유지된다. 필요한 것은 번호 검색이므로, 맵의 키를 다시 순회할 이유도 없다.

객체 생성도 같은 기준으로 검토한다. 예제의 표시 도우미는 바뀌지 않는 접두 문구만 보관한다. 대출마다 새로 만들 이유가 없으므로 메서드 호출 안에서 한 번 생성해 재사용할 수 있다. 반면 대출별 결과 문자열과 결과 목록은 반환할 값을 담는 데 필요하다. 객체가 생성된다는 사실만으로 제거 대상으로 삼아서는 안 된다.

지역 객체를 재사용하는 변경과 프로그램 전체가 공유하는 객체로 바꾸는 변경은 범위가 다르다. 이 예제에서는 한 호출 안에서만 도우미를 사용한다. 성능을 개선한다는 이유로 객체의 수명을 필요 이상으로 늘리거나 전역 보관소를 추가하지 않는다.

도서 맵을 한 번 구성한 뒤 대출 순서대로 조회하면 검색과 결과 순서를 분리할 수 있다

측정값이 설명하는 범위를 정한다

측정에는 먼저 질문이 필요하다. “사용자가 얼마나 기다리는가”를 알고 싶다면 실제 요청의 수행 시간을 살펴야 한다. “입력이 늘어날 때 검색 작업이 얼마나 증가하는가”를 알고 싶다면 연산 횟수가 더 직접적인 근거가 될 수 있다. 이 장의 프로그램은 두 번째 질문에 답한다.

리스트에서는 도서 번호를 비교할 때마다 횟수를 올린다. 맵에서는 구성 과정의 putIfAbsent 호출과 검색 과정의 get 호출을 각각 센다. 도우미를 만드는 횟수도 별도로 센다. 모두 코드에서 관찰한 작업 횟수이므로 결과는 실행할 때마다 같다.

다만 리스트 비교 한 번과 맵 조회 한 번은 비용이 같은 연산이 아니다. 맵 내부에서는 해시 계산과 키 비교 등이 일어날 수 있다. 따라서 리스트 비교가 820번이고 맵 조회가 40번이라고 해서 실행 시간이 특정 배수만큼 줄었다고 결론 내릴 수 없다. 구성 호출 40번도 함께 보고, 필요하면 실제 환경에서 시간을 추가로 측정해야 한다.

시간을 재는 경우에도 한 번 실행한 작은 숫자만으로 판단하기는 어렵다. 실행 초기의 준비 작업, 자바 가상 머신의 코드 최적화, 다른 프로세스의 활동, 메모리 정리 등이 관찰값에 영향을 줄 수 있다. 입력 크기와 데이터 배치를 명시하고 여러 번 관찰하되, 결과를 출력하는 작업까지 검색 시간에 섞지 않도록 범위를 정한다.

현재 결과는 번호 비교 횟수의 증가를 보여 준다. 맵의 구성 비용도 별도로 표시되어 있어 대안을 검토하기 쉽다. 실제 화면 응답 시간의 개선 폭은 이 숫자로 확정하지 않고, 화면에서 사용하는 입력 규모로 추가 확인하자.

이제 같은 입력의 결과가 같은지 확인한 뒤 작업량을 비교한다. 기본 입력 외에도 도서 정보 누락, 같은 번호의 도서 중복, 빈 대출 목록을 실행한다. 최적화의 근거와 동작 유지의 근거를 한 프로그램에서 함께 확인하는 구성이다.

완성 코드

다음 파일은 JDK 17 표준 라이브러리만 사용한다. before는 기존 리스트 검색과 대출별 도우미 생성을, after는 맵 검색과 호출별 도우미 생성을 나타낸다. record는 입력과 결과를 묶는 데 사용한다. 외부 상태, 파일, 현재 시각, 난수에 의존하지 않는다.

Main.java

import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;

public class Main {
    record Book(int id, String title) {}
    record Loan(int bookId) {}

    record Result(
            List<String> lines,
            long comparisons,
            long indexCalls,
            long lookupCalls,
            long formatterCreations) {}

    static final class Formatter {
        private final String prefix;

        Formatter(String prefix) {
            this.prefix = prefix;
        }

        String format(Loan loan, Book book) {
            String title = book == null ? "(도서 정보 없음)" : book.title();
            return prefix + loan.bookId() + ": " + title;
        }
    }

    static Result before(List<Loan> loans, List<Book> books) {
        List<String> lines = new ArrayList<>();
        long comparisons = 0;
        long formatterCreations = 0;

        for (Loan loan : loans) {
            Book found = null;
            for (Book book : books) {
                comparisons++;
                if (book.id() == loan.bookId()) {
                    found = book;
                    break;
                }
            }
            Formatter formatter = new Formatter("대출 ");
            formatterCreations++;
            lines.add(formatter.format(loan, found));
        }
        return new Result(
                List.copyOf(lines), comparisons, 0, 0, formatterCreations);
    }

    static Result after(List<Loan> loans, List<Book> books) {
        Map<Integer, Book> index = new HashMap<>();
        long indexCalls = 0;
        for (Book book : books) {
            index.putIfAbsent(book.id(), book);
            indexCalls++;
        }

        List<String> lines = new ArrayList<>();
        Formatter formatter = new Formatter("대출 ");
        long lookupCalls = 0;
        for (Loan loan : loans) {
            Book found = index.get(loan.bookId());
            lookupCalls++;
            lines.add(formatter.format(loan, found));
        }
        return new Result(List.copyOf(lines), 0, indexCalls, lookupCalls, 1);
    }

    static List<Book> books(int size) {
        List<Book> result = new ArrayList<>();
        for (int id = 1; id <= size; id++) {
            result.add(new Book(id, "책" + id));
        }
        return result;
    }

    static List<Loan> loans(int size) {
        List<Loan> result = new ArrayList<>();
        for (int id = 1; id <= size; id++) {
            result.add(new Loan(id));
        }
        return result;
    }

    static void check(boolean condition, String name) {
        System.out.println((condition ? "PASS " : "FAIL ") + name);
        if (!condition) {
            throw new AssertionError(name);
        }
    }

    public static void main(String[] args) {
        for (int size : new int[] {4, 40, 400}) {
            List<Book> books = books(size);
            List<Loan> loans = loans(size);
            Result oldResult = before(loans, books);
            Result newResult = after(loans, books);

            check(oldResult.lines().equals(newResult.lines()),
                    "같은 결과 n=" + size);
            long expected = (long) size * (size + 1) / 2;
            check(oldResult.comparisons() == expected,
                    "리스트 비교 n=" + size);
            check(newResult.indexCalls() == size
                            && newResult.lookupCalls() == size,
                    "맵 호출 n=" + size);
            check(oldResult.formatterCreations() == size
                            && newResult.formatterCreations() == 1,
                    "도우미 생성 n=" + size);

            System.out.println(
                    "n=" + size
                    + " | 리스트 비교=" + oldResult.comparisons()
                    + " | 맵 구성 호출=" + newResult.indexCalls()
                    + " | 맵 조회 호출=" + newResult.lookupCalls()
                    + " | 도우미 생성=" + oldResult.formatterCreations()
                    + "/" + newResult.formatterCreations());
            if (size == 4) {
                System.out.println("표시 결과=" + newResult.lines());
            }
        }

        List<Book> specialBooks = List.of(
                new Book(1, "처음 제목"),
                new Book(1, "나중 제목"));
        List<Loan> specialLoans = List.of(new Loan(9), new Loan(1));
        List<String> expectedLines = List.of(
                "대출 9: (도서 정보 없음)",
                "대출 1: 처음 제목");
        Result specialBefore = before(specialLoans, specialBooks);
        Result specialAfter = after(specialLoans, specialBooks);

        check(specialBefore.lines().equals(expectedLines)
                        && specialAfter.lines().equals(expectedLines),
                "누락·중복·대출 순서 유지");
        System.out.println("특수 입력 결과=" + specialAfter.lines());

        List<Loan> emptyLoans = List.of();
        Result emptyBefore = before(emptyLoans, specialBooks);
        Result emptyAfter = after(emptyLoans, specialBooks);
        check(emptyBefore.lines().isEmpty()
                        && emptyAfter.lines().isEmpty(),
                "빈 대출 결과");
    }
}

줄별 해설

Book과 Loan 선언은 도서 정보와 대출 정보를 구분한다. 도서에는 번호와 제목이 있고 대출에는 도서 번호만 있다. 이 차이 때문에 표시 문구를 만들 때 조회가 필요하다. 예제의 입력 목록과 그 안의 객체는 null이 아니며, 도서 제목도 null이 아니라는 전제로 범위를 한정한다.

Result 선언은 표시 결과와 작업 횟수를 함께 반환한다. comparisons는 리스트의 번호 비교 횟수다. indexCalls는 맵 구성 메서드의 호출 횟수이며, 중복 번호가 있어도 호출할 때마다 증가한다. 따라서 맵에 저장된 항목 수와 항상 같지는 않다.

Formatter의 format 메서드는 조회에 성공한 도서 또는 조회 실패를 나타내는 null을 받아 표시 문구를 만든다. 전후 구현이 이 메서드를 함께 사용하므로 문구 구성 규칙을 그대로 유지한다. 접두 문구는 생성 후 바뀌지 않으며, 대출별 진행 상태를 저장하지 않는다.

before의 안쪽 반복문은 번호를 비교하기 직전에 comparisons를 올린다. 찾으면 break로 검색을 끝내므로 실제로 수행한 비교만 센다. 도서를 찾지 못하면 found는 null로 남는다. 도우미는 대출마다 생성하므로 생성 횟수가 대출 수와 같다.

after의 첫 반복문은 도서 목록으로 맵을 만든다. putIfAbsent는 이미 값이 있으면 기존 값을 유지하므로, 처음 일치하는 도서를 선택하던 리스트 검색의 규칙과 맞는다. 입력 도서가 null이 아니라는 전제 덕분에 저장된 null 값에 대한 별도 처리는 필요하지 않다.

after의 두 번째 반복문은 대출 하나마다 get을 한 번 호출한다. 도우미는 그 앞에서 한 번 생성한다. 두 반복문은 서로 안에 들어 있지 않다. 구성 호출 수는 도서 수에, 조회 호출 수는 대출 수에 따라 각각 증가한다. 반환 전 List.copyOf로 결과 목록을 복사하는 작업은 전후에 동일하게 적용한다.

books와 loans 메서드는 같은 순서의 번호를 만든다. 이 배치 때문에 리스트 비교 횟수를 수식으로 예상할 수 있다. 예상값을 구할 때는 곱셈 전에 size를 long으로 변환한다. 더 큰 입력으로 바꾸더라도 int 곱셈이 먼저 수행되는 상황을 피하려는 것이다.

check 메서드는 조건과 검사 이름을 출력하고, 실패하면 실행을 멈춘다. 앞의 구현 결과와 뒤의 구현 결과는 화면에 두 번 나열하는 대신 List.equals로 비교한다. 이 비교는 문자열 내용뿐 아니라 목록의 순서도 확인한다.

특수 입력 검사는 누락 도서를 앞에 두고, 같은 번호의 도서를 두 번 제공한다. 기대 결과를 직접 적어 두었으므로 두 구현이 같은 방식으로 잘못 바뀌는 상황도 일부 확인할 수 있다. 빈 대출 검사에서는 결과만 비교한다. 현재 after는 빈 대출에서도 맵과 도우미를 만들며, 이 비용이 사라졌다고 주장하지 않는다.

실행 결과

Main.java가 있는 디렉터리에서 다음 명령을 순서대로 실행한다. 아래 명령 블록은 셸에서 실행하는 명령이며, Java 소스에 넣지 않는다.

javac -d out *.java
java -cp out Main

컴파일이 성공하면 컴파일 명령은 별도 메시지를 출력하지 않는다. 프로그램의 예상 출력은 다음과 같다.

PASS 같은 결과 n=4
PASS 리스트 비교 n=4
PASS 맵 호출 n=4
PASS 도우미 생성 n=4
n=4 | 리스트 비교=10 | 맵 구성 호출=4 | 맵 조회 호출=4 | 도우미 생성=4/1
표시 결과=[대출 1: 책1, 대출 2: 책2, 대출 3: 책3, 대출 4: 책4]
PASS 같은 결과 n=40
PASS 리스트 비교 n=40
PASS 맵 호출 n=40
PASS 도우미 생성 n=40
n=40 | 리스트 비교=820 | 맵 구성 호출=40 | 맵 조회 호출=40 | 도우미 생성=40/1
PASS 같은 결과 n=400
PASS 리스트 비교 n=400
PASS 맵 호출 n=400
PASS 도우미 생성 n=400
n=400 | 리스트 비교=80200 | 맵 구성 호출=400 | 맵 조회 호출=400 | 도우미 생성=400/1
PASS 누락·중복·대출 순서 유지
특수 입력 결과=[대출 9: (도서 정보 없음), 대출 1: 처음 제목]
PASS 빈 대출 결과

입력이 4에서 40으로 열 배 늘었을 때 리스트 비교는 10에서 820으로 늘어난다. 맵 구성과 조회 호출은 각각 4에서 40으로 늘어난다. 이 결과는 입력 증가에 따른 작업량의 차이를 보여 준다. 화면 응답 시간이 얼마나 줄었는지, 메모리를 얼마나 사용하는지까지 보여 주지는 않는다.

도우미 생성 수의 감소 역시 소스 코드에서 생성자를 호출한 횟수에 대한 결과다. 실행 환경의 최적화까지 고려한 실제 메모리 할당량을 측정한 값은 아니다. 리뷰 설명에는 “도우미 생성 코드의 실행을 대출 수만큼에서 한 번으로 줄였다” 정도로 확인한 범위를 명확하게 적는다.

실무에서 자주 틀리는 것

맵을 만들면서 같은 번호의 의미를 바꾼다

성능을 바꾸는 과정에서 중복 처리 규칙이 달라지기 쉽다. 다음 예제의 before는 기존 리스트 검색에 대한 잘못된 대체안이다. 같은 번호가 들어올 때 나중 제목으로 덮어쓴다. after는 먼저 있던 제목을 유지한다. 독립된 파일 DuplicateExample.java로 실행할 수 있다.

import java.util.HashMap;
import java.util.Map;

class DuplicateExample {
    static String before() {
        Map<Integer, String> titles = new HashMap<>();
        titles.put(1, "처음 제목");
        titles.put(1, "나중 제목");
        return titles.get(1);
    }

    static String after() {
        Map<Integer, String> titles = new HashMap<>();
        titles.putIfAbsent(1, "처음 제목");
        titles.putIfAbsent(1, "나중 제목");
        return titles.get(1);
    }

    public static void main(String[] args) {
        System.out.println(before());
        System.out.println(after());
    }
}

출력은 차례로 “나중 제목”, “처음 제목”이다. 두 결과가 다르므로 첫 대체안은 기존 동작을 유지하지 못한다. 실제 서비스에서 중복 번호를 허용하지 않더라도, 그 제약이 어디서 보장되는지 확인한 뒤 단순화해야 한다.

맵 구성을 반복 안으로 옮긴다

조회 한 줄만 get으로 바꾸어도 맵을 매번 다시 만들면 구성 비용이 반복된다. 다음 예제는 동일한 번호 두 개를 찾는다. before와 after 모두 결과는 같지만, before는 도서 목록을 두 번 맵에 넣는다. 고친 코드는 두 번의 조회에서 한 맵을 사용한다.

import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;

class IndexExample {
    static Map<Integer, String> buildIndex(List<String> titles) {
        Map<Integer, String> index = new HashMap<>();
        for (int i = 0; i < titles.size(); i++) {
            index.put(i + 1, titles.get(i));
        }
        return index;
    }

    static List<String> before(List<Integer> ids, List<String> titles) {
        List<String> result = new ArrayList<>();
        for (int id : ids) {
            Map<Integer, String> index = buildIndex(titles);
            result.add(index.get(id));
        }
        return result;
    }

    static List<String> after(List<Integer> ids, List<String> titles) {
        Map<Integer, String> index = buildIndex(titles);
        List<String> result = new ArrayList<>();
        for (int id : ids) {
            result.add(index.get(id));
        }
        return result;
    }

    public static void main(String[] args) {
        List<Integer> ids = List.of(2, 1);
        List<String> titles = List.of("책1", "책2");
        boolean same = before(ids, titles).equals(after(ids, titles));
        System.out.println((same ? "PASS " : "FAIL ") + "같은 결과");
        if (!same) {
            throw new AssertionError("같은 결과");
        }
    }
}

실행 결과는 “PASS 같은 결과”다. 최적화 대상은 get 호출만이 아니라 그 조회를 준비하는 작업까지 포함한다. 완성 코드에서 맵 구성 호출을 별도로 센 이유도 이 비용을 검토에서 빠뜨리지 않기 위해서다.

같은 접두 문구를 가진 객체를 매번 만든다

반복별 상태가 없는 객체를 매번 생성하면 코드를 읽는 사람도 객체마다 무엇이 달라지는지 살피게 된다. 다음 before는 같은 접두 문구를 가진 객체를 반복해서 만든다. after는 한 번 만든 객체를 사용한다. 결과 문자열은 각 입력에 맞게 계속 생성한다.

import java.util.ArrayList;
import java.util.List;

class FormatterExample {
    record Labeler(String prefix) {
        String format(int id) {
            return prefix + id;
        }
    }

    static List<String> before(List<Integer> ids) {
        List<String> result = new ArrayList<>();
        for (int id : ids) {
            Labeler labeler = new Labeler("대출 ");
            result.add(labeler.format(id));
        }
        return result;
    }

    static List<String> after(List<Integer> ids) {
        List<String> result = new ArrayList<>();
        Labeler labeler = new Labeler("대출 ");
        for (int id : ids) {
            result.add(labeler.format(id));
        }
        return result;
    }

    public static void main(String[] args) {
        List<Integer> ids = List.of(1, 2);
        boolean same = before(ids).equals(after(ids));
        System.out.println((same ? "PASS " : "FAIL ") + "같은 결과");
        if (!same) {
            throw new AssertionError("같은 결과");
        }
    }
}

실행 결과는 “PASS 같은 결과”다. 다만 객체를 반복 밖으로 옮겼다는 사실만으로 유의미한 시간 단축을 주장하지 않는다. 이 변경은 동일한 설정을 반복하지 않도록 만들며 생성 횟수도 줄인다. 실제 병목인지에 대한 판단은 별도의 관찰이 필요하다. 필요하지 않은 도우미라면 직접 문자열을 구성하는 더 단순한 대안도 검토할 수 있다.

한눈에 보기

성능 리뷰에서 관찰과 결론을 연결하는 기준
검토 대상확인 방법말할 수 있는 결론추가 확인
반복 안의 리스트 검색번호 비교 횟수 집계입력 증가에 따른 비교량실제 입력의 배치와 크기
번호별 맵구성·조회 호출 분리반복 검색 구조의 변화구성 비용과 추가 메모리
도우미 재사용생성자 호출 횟수 집계반복 생성 코드의 감소실제 할당량과 시간 영향
동작 유지동일 입력의 결과 비교검사한 사례의 결과 일치중복·누락·순서 조건
실제 응답 시간환경과 범위를 정해 반복 관찰해당 조건의 수행 시간다른 입력과 실행 변동

리뷰 의견에는 추측을 관찰처럼 적지 않는다. “맵이므로 빠르다”보다 “이 입력에서 번호 비교는 80,200번이며, 대안은 구성 호출 400번과 조회 호출 400번이다”가 검토하기 쉽다. 이어서 결과 일치 여부와 아직 측정하지 않은 항목을 적으면 변경의 근거가 분명해진다.

연습 문제

  1. 완성 코드의 도서 수와 대출 수를 모두 8로 바꾸고, 번호와 순서는 그대로 유지한다. 리스트 비교 횟수, 맵 구성 호출 수, 맵 조회 호출 수를 실행 전에 계산하라.
  2. 도서는 1번부터 40번까지 있고 대출은 존재하지 않는 99번 도서에 대한 내역만 40건이라고 하자. 리스트 비교 횟수와 맵 조회 호출 수를 구하고, 기존 입력과 다른 이유를 설명하라.
  3. 완성 코드의 putIfAbsent를 put으로 바꾸면 어떤 검사가 실패하는지 설명하라. “더 나중에 입력한 도서 제목을 사용한다”가 새 요구사항이라면 전후 비교 기준도 어떻게 바뀌어야 하는지 적어라.
  4. 도서는 많지만 대출이 비어 있는 요청이 자주 들어온다. after에서 줄일 수 있는 작업과 유지해야 하는 반환 결과를 설명하라. 이 변경을 모든 요청의 수행 시간 개선으로 표현해도 되는지 판단하라.

정답과 해설

  1. 리스트 비교는 1부터 8까지의 합인 36번이다. 맵 구성 호출과 조회 호출은 각각 8번이다. 이 계산은 대출 번호와 도서 번호가 같은 순서로 한 번씩 나온다는 입력 조건에 따른다. 입력 크기만 같다고 리스트 비교가 언제나 36번인 것은 아니다.
  2. 리스트 비교는 40 × 40으로 1,600번이다. 없는 번호는 매번 목록 끝까지 확인하기 때문이다. 맵 조회 호출은 대출 수와 같은 40번이며, 구성 호출도 40번이다. 기존 입력에서는 앞쪽 도서를 찾을 때 검색이 일찍 끝났지만, 이번 입력에서는 조기 종료가 없다.
  3. “누락·중복·대출 순서 유지” 검사가 실패한다. 1번 도서의 제목이 “나중 제목”으로 바뀌기 때문이다. 새 요구사항이 나중 제목 선택이라면 기대 결과를 그 규칙에 맞게 바꾸고, 기존 구현과의 단순한 결과 일치 대신 의도한 동작 변경을 확인해야 한다. 성능 변경과 결과 변경의 근거도 각각 설명한다.
  4. 대출이 비었으면 빈 결과와 0인 작업 횟수를 담은 Result를 일찍 반환할 수 있다. 이 경우 맵 구성과 도우미 생성을 생략한다. 반환 목록이 비어 있다는 기존 동작은 유지해야 하며, 호출 횟수를 기록하는 값도 실제 수행에 맞추어야 한다. 줄어든 작업은 빈 대출 경로에 해당하므로 모든 요청의 시간 개선으로 확대해서 말할 수 없다.

성능 리뷰에서 필요한 습관은 의심한 작업을 세고, 입력 조건을 적고, 바뀐 결과를 확인하는 것이다. 자료구조 이름만으로 결론을 내리지 않고 구성 비용과 결과의 의미까지 살펴야 한다. 다음 장에서는 이런 확인을 담은 테스트 코드 자체를 읽으며, 검사가 요구한 동작을 충분히 드러내는지 살펴본다.

오탈자·오류 제보 비공개로 접수되어 원고 수정에 반영됩니다

이메일 등 개인정보는 받지 않습니다. 답변이 필요한 질문은 아래 댓글을 이용해 주세요.

READER FEEDBACK

질문·의견

내용에 관한 질문이나 더 나은 설명을 위한 의견을 남겨 주세요. 오탈자는 위의 제보 양식이 더 빨리 반영됩니다. 이 댓글은 원래 게시글과 같은 자리에 쌓입니다.

댓글 0

아직 댓글이 없습니다. 첫 댓글을 남겨 보세요.

댓글을 남기려면 로그인이 필요합니다.