thumbnail
public 메소드에 의해 반환된 private 컬렉션(배열)이 왜 위험할까
보안
2025.11.07.

개요

private 접근 제어자로 선언된 배열 또는 컬렉션 타입 필드의 객체를 private 메소드를 통해 그대로 반환하지 않도록 해야 한다.

필요한 경우 복사본을 생성하여 반환하거나, 접근 및 수정을 제어하는 별도의 public 메소드를 선언하여 사용해야 한다. 이렇게 함으로써 외부에서 조작하는 것을 방지할 수 있다.

어떻게 대비할 수 있는지 알아보자

취약점 존재 예시

public class DangerousPrivateArray {
    private String[] fruits;
    public String[] getFruits() {
        return this.fruits;
    }

    public DangerousPrivateArray() {
        this.fruits = new String[] {"grape", "apple", "banana", "orange", "strawberry"};
    }

    public static void main(String[] args) {
        DangerousPrivateArray innerData = new DangerousPrivateArray();
        String[] outerData = innerData.getFruits();
        outerData[2] = "watermelon";
    }
}

위와 같이, DangerousPrivateArray 클래스의 필드 변수가 private으로 선언되어 있어 외부로의 접근을 막을 수 있겠다 생각할 수 있지만, 그렇지 않다. getFruits() 메소드를 통해 fruits 객체의 주소를 그대로 반환하고 있다. 이렇게 하면 main 메소드에서 선언한 innerData객체로 클래스 안에 있던 fruits 객체를 그대로 꺼낼 수가 있다. 그 객체 주소를 다른 새 객체에 복사하면 그 새 객체에서도 private 접근제어자로 선언한 fruits 객체의 내용을 바꿀 수 있게 된다.

그러면 어떻게 상태의 일관성을 망치지 않을 수 있을까?

취약점 해결 방법

public class ProtectPrivateArrayByPublicMethod {
    private String[] fruits;
    public String[] getFruits() {
        String[] safeArray = new String[this.fruits.length];
        for (int i = 0; i < safeArray.length; i++) {
            safeArray[i] = fruits[i];
        }
        return safeArray;
    }

    public static void main(String[] args) {
        ProtectPrivateArrayByPublicMethod innerData = new ProtectPrivateArrayByPublicMethod();
        String[] outerData = innerData.getFruits();
        outerData[2] = "watermelon";
    }
}

바로 새 배열, 즉 값만 복사한 뒤 새 객체 안에 채운다. 그러면 outerData의 3번째 인덱스 값만 watermelon으로 바뀌고, 원본 private 배열에는 영향이 없게 되어 캡슐화를 지킬 수 있게 된다. 즉, 상태의 일관성을 지킬 수 있게 된다.

꼬리에 꼬리를 무는 궁금증

Q. String의 배열 요소는 참조형 클래스인 String일텐데, 참조주소를 넘기는 게 아닌가요?

그렇다. 참조주소를 넘기는 건 맞다. 하지만, String은 불변 객체이기 때문에 참조 주소를 공유해도 내부 상태가 바뀌지 않는다.

그러므로 위의 취약점 해결 방법으로 방어적 복사를 하여 상태의 일관성을 지킬 수 있다.

그러나 배열 요소 객체의 변경 가능성이 있다면(가변 객체라면) 얘기가 달라진다.

그때는 새 배열 safeArray 안의 요소를 통해 객체 상태를 바꿀 수 있고, 그 객체를 참조하는 원본 배열에서도 변경이 가능하게 된다.

이런 경우에는 배열 구조는 보호되지만, 요소의 상태는 노출되는 것이다. 이때는 깊은 복사 또는 불변 래퍼까지 고려해야 한다.

Q. 불변 래퍼 예시도 들어주세요.

불변 래퍼는 외부에서 수정만 못 하게 막거나 읽기 전용으로만 노출하고 싶을 때 활용할 수 있다.

얕은 복사만 하는 경우의 문제 상황

먼저, 가변 객체에 대해 얕은 복사만 한 경우를 예시로 들겠다. 가변 객체(도메인 클래스)를 선언하면 다음과 같다.

public class Address {
    private String city;
    private String street;

    public Address(String city, String street) {
        this.city = city;
        this.street = street;
    }

    // setter가 있으므로 가변 객체가 됨
    public void setCity(String city) { this.city = city; }
    public void setStreet(String street) { this.street = street; }

    @Override
    public String toString() {
        return city + " " + street;
    }
}

getAddresses()를 통해 내부 리스트를 그대로 반환하게 되어 변경에 노출된다.

public class UserInfo {
    private List<Address> addresses = new ArrayList<>();

    public UserInfo() {
        addresses.add(new Address("Seoul", "Gangnam-daero"));
        addresses.add(new Address("Busan", "Haeundae-ro"));
    }

    public List<Address> getAddresses() {
        return addresses;
    }
}

이렇게 내부 객체를 직접 수정하고 내부 리스트 구조까지 변경할 수 있게 되어 캡슐화가 깨지게 된다.

UserInfo info = new UserInfo();
List<Address> outer = info.getAddresses();
outer.get(0).setCity("HackedCity"); // 내부 객체 직접 수정
outer.remove(1); // 내부 리스트 구조까지 변경 가능

이를 UserInfo쪽에서 불변 래퍼(Unmodifiable Wrapper)로 가변 객체를 방어할 수 있다. 즉, 읽기 전용 래퍼로 감싸는 게 된다.

public class UserInfo {
    private final List<Address> addresses = new ArrayList<>();

    public UserInfo() {
        addresses.add(new Address("Seoul", "Gangnam-daero"));
        addresses.add(new Address("Busan", "Haeundae-ro"));
    }

    public List<Address> getAddresses() {
        return Collections.unmodifiableList(addresses);
    }
}
UserInfo info = new UserInfo();
List<Address> outer = info.getAddresses();

outer.remove(0); // UnsupportedOperationException 발생
outer.add(new Address("X", "Y")); // UnsupportedOperationException 발생

그러나 이렇게 구조 보호를 할 수는 있지만

outer.get(0).setCity("HackedCity");

요소는 여전히 가변이라 접근이 가능하다. 즉, unmodifiableList는 리스트 구조만 막을 수 있고 요소 객체까지 불변으로 만들어주진 못한다.

Q. 그러면 요소까지 방어할 수 있는 방법이 있을까요?

이전까지만 해도 public 메소드에 의해 반환된 private 컬렉션(배열)에 대한 시큐어 코딩은 충분하다. 왜냐하면 위의 요점은 내부 배열 직접 노출하는 것을 막기이지, 지금부터 하는 것은 시큐어 코딩을 넘어서서 추가적인 궁금증에 대한 학습을 기록으로 남긴 것이다.

요소까지 방어하고 싶다면 깊은 복사를 활용할 수 있다. 깊은 복사만 활용하거나, 아니면 깊은 복사 + 불변 래퍼까지 섞어서 활용할 수 있다.

1. 깊은 복사 예시

/**
 * Copy Constructor를 둔 가변 클래스
 **/
public class Address {
    private String city;
    private String street;

    public Address(String city, String street) {
        this.city = city;
        this.street = street;
    }

    public Address(Address other) {
        this.city = other.city;
        this.street = other.street;
    }

    public void setCity(String city) { this.city = city; }
    public void setStreet(String street) { this.street = street; }

    @Override
    public String toString() {
        return city + " " + street;
    }
}
public class UserInfo {
    private final List<Address> address = new ArrayList<>();

    public UserInfo() {
        addresses.add(new Address("Seoul", "Gangnam-daero"));
        addresses.add(new Address("Busan", "Haeundae-ro"));
    }

    // getter에서 깊은 복사 적용
    public List<Address> getAddresses() {
        List<Address> copy = new ArrayList<>(addresses.size());
        for (Address addr : addresses) {
            copy.add(new Address(addr));
        }
        return copy;
    }
}
UserInfo info = new UserInfo();
List<Address> outer = info.getAddresses();

outer.get(0).setCity("HackedCity");
outer.remove(1);

System.out.println(outer);       // 바뀐 건 복사본
System.out.println(info.getAddresses()); // 원본은 그대로

깊은 복사만 했을 때는 새 리스트로 구조를 보호할 수 있고, 요소 객체도 새 Address로 복제된다. 즉, 외부에서 수정할 때 내부에 영향을 안 미치는 deep copy가 된다.

그러나 불변 래퍼 방식이나 얕은 복사에 비해 비용이 든다는 단점이 있다.

2. 깊은 복사 + 불변 래퍼 섞은 예시

...

public List<Address> getAddresses() {
    List<Address> copy = new ArrayList<>(addresses.size());
    for (Address addr : addresses) {
        copy.add(new Address(addr));
    }
    return Collections.unmodifiableList(copy);
}

...

이렇게 한다면, 방금 전 깊은 복사만 했을 때와 같이 외부에서는 구조를 변경할 수 없고 요소도 모두 복제된 객체라 원본을 오염시킬 수 없게 된다.

Q. 깊은 복사만 했을 때와 깊은 복사와 불변 래퍼까지 했을 때의 차이가 뭔가요?

unmodifiableList를 사용하면서, 구조를 변경하려는 다른 사용자에게 읽기 전용이라고 코드 레벨에서 강제할 수 있다. 즉, API를 사용할 때 실수나 오해까지 줄여줄 수 있다.

Q. 깊은 복사를 할 때의 비용은 어떤 비용을 말씀하시는 걸까요?

바로

  1. 연산 비용
  2. 메모리 사용량
  3. GC 부담
  4. 코드 복잡도 (유지보수 비용)

가 있겠다.

다만, 유지보수 비용은 개인적으로 깊은 복사를 했다고 비용이 더 많이 든다고 생각하지는 않는다. 의도를 정확하게 전달하고 싶다면 되려 모호한 표현보다는 명확한 표현이 더 낫다고 생각한다.

그러면 나머지 세 가지를 보자.

1. 연산 비용

...

public List<Address> getAddresses() {
    List<Address> copy = new ArrayList<>(addresses.size());
    for (Address addr : addresses) {
        copy.add(new Address(addr));
    }
    return copy;
}

...

여기서 copy.add(new Address(addr)) 로 인해 요소를 매번 새로 생성한다. 이때 드는 시간 비용을 시간복잡도로 표현하면 O(N)이 된다. 요소 개수가 n개일 때, O(n) 번의 생성과 대입 연산이 발생하기 때문이다.

객체가 VO(Value Object)라면 값이 쌀 수는 있지만, 내부에 컬렉션, 검증 로직, 추가 연산이 있다면 복사하는 것 자체가 비용이 많이 들게 된다. getAddresses()를 한 번에 요청을 많이 한다면 그만큼 deep copy가 발생할 거고, 그에 따른 비용이 누적된다. 그러므로, shallow copy(배열만 새로 만들고 요소 참조는 복사 가능)보다 deep copy(객체부터 요소까지 새로 생성)가 더 많은 CPU 연산 시간을 할애하게 된다.

2. Heap 메모리 비용

deep copy를 하면 동시에 더 많은 객체를 메모리에 올린다.

예를 들어 원본 리스트에 Address가 10000개가 있을 때 매 요청마다 deep copy해서 반환한다면, 한 시점에 원본 10000개 + 복사본 10000개가 공존할 수 있게 된다. 그만큼 추가 Heap 메모리를 사용하게 된다. 만약, 대량의 객체/리스트를 deep copy한다면 Heap 메모리에 대한 압박이 커질 거고, 생판 다른 요청들까지 합세하여 겹친다면 OOM으로도 이어질 수 있다.

그에 반면, 얕은 복사는 새 배열/리스트만 추가로 하나 만들고 요소는 참조만 복사하므로 증가분이 훨씬 적다.

3. GC(Garbage Collection) 비용

deep copy를 하면 새 객체를 생성하게 되고, 사용한 뒤 금방 버려진다. 그 결과로, GC 입장에서는 금방 죽는 객체들을 자주 치워야 해서 Minor GC 빈도가 증가하며, 이에 따라 Stop-the-world 시간이 조금씩 늘어나게 된다.

요청이 많이 들어올 때, deep copy를 많이 반복하게 된다면 GC에 있어 병목이 발생할 수 있다.

그에 비해 얕은 복사는 생성하는 객체 수가 훨씬 적어서 GC 부담도 상대적으로 적다.

Q. 불변 래퍼 예시에서 나왔던 Collections.unmodifiableList()는 쓰다가 List의 기본 capacity를 벗어나면 어떻게 되나요?

...

public List<Address> getAddresses() {
    List<Address> copy = new ArrayList<>(addresses.size());
    for (Address addr : addresses) {
        copy.add(new Address(addr));
    }
    return Collections.unmodifiableList(copy);
}

...

위 코드에서는 요소를 다 넣고 Collections.unmodifiableList(copy)로 감싸서 반환한다. copy에 요소를 더 이상 넣지 않고, capacity를 초과하는 추가 add가 발생하지 않는다. 즉, 내부 리사이즈 이슈 자체가 없다. 설령 리사이즈가 일어나도 wrapper는 리스트 객체를 참조하고 있을 분, 내부 배열을 직접 참조하는 게 아니다.

만약 capacity가 초과된다면 ArrayList는 내부적으로 새 배열을 만들고 갈아끼운다. 하지만, copy라는 List 인스턴스는 그대로이고, unmodifiableList도 같은 인스턴스를 보고 있으므로 깨지지 않는다. 다시 말해, 리사이즈가 일어나도 unmodifiableList는 그 리스트 객체를 보고 있기 때문에, 내부 배열 교체는 외부 뷰를 깨지 않는다.

따라서 리스트를 감싸는 Collections.unmodifiableList(list)도 깨지지 않는다.

Q. 취약점 해결 소스를 for문 말고 람다식으로도 표현해주세요

public class ProtectPrivateArrayByPublicMethod {
    private String[] fruits;
    public String[] getFruits() {
        return fruits == null
               ? new String[0]
               : Arrays.stream(fruits).toArray(String[]::new);
    }

    ...
}

NullPointerException도 취약점 중 하나이니, 이를 방어하면서 stream으로 한 줄로 표현할 수 있다.


© 2025 Bruce Han, Powered By Gatsby.