C++ friend: private 접근을 열어 줄 때, operator<<와 대칭 연산자, getter와 비교

이 글의 핵심

friend는 캡슐화를 깨는 키워드로 오해받지만, 접근 권한을 누구에게 줄지 클래스가 직접 정한다는 점에서 public getter를 여럿 여는 것보다 좁은 공개일 수 있습니다. 행렬, 분수, 스트림 연산자 예제로 friend가 자연스러운 경우를 보고, 양방향 friend나 테스트 편의를 위한 남용처럼 설계 문제의 신호가 되는 경우와 그 대안을 정리했습니다.

friend란?

friend 키워드는 다른 클래스나 함수가 private 또는 protected 멤버에 접근할 수 있도록 허용합니다. 이는 캡슐화를 유지하면서도 특정 외부 함수나 클래스에게 제한적인 접근 권한을 부여하는 메커니즘입니다.

// 타입 정의
class Box {
private:
    int width;
    
public:
    Box(int w) : width(w) {}
    
    // friend 함수 선언
    friend void printWidth(const Box& box);
};

// friend 함수 정의
void printWidth(const Box& box) {
    cout << "Width: " << box.width << endl;  // private 접근 가능
}

int main() {
    Box box(10);
    printWidth(box);  // Width: 10
}

왜 필요한가?:

  • 연산자 오버로딩: operator<<, operator+ 등을 비멤버 함수로 구현
  • 헬퍼 함수: 클래스와 밀접하게 관련된 유틸리티 함수
  • 클래스 간 협력: 두 클래스가 서로의 내부 구현을 알아야 할 때
  • 캡슐화 유지: public getter/setter 없이 선택적 접근
// ❌ public getter: 모든 코드가 접근 가능
// 타입 정의
class Box {
public:
    int getWidth() const { return width; }
private:
    int width;
};

// ✅ friend: 특정 함수만 접근 가능
class Box {
private:
    int width;
    friend void printWidth(const Box& box);
};

friend의 종류:

  1. friend 함수: 특정 함수가 private 멤버에 접근
  2. friend 클래스: 특정 클래스의 모든 멤버 함수가 접근
  3. friend 멤버 함수: 특정 클래스의 특정 멤버 함수만 접근
class A {
private:
    int data;
    
    // friend 함수
    friend void func(const A& a);
    
    // friend 클래스
    friend class B;
    
    // friend 멤버 함수 (C가 이 시점에 완전히 정의되어 있어야 함)
    friend void C::process(const A& a);
};

friend 선언은 private/public 구역 어디에 적어도 의미가 같습니다. 접근 지정자는 멤버에 적용되는데 friend는 멤버가 아니기 때문입니다. 관례적으로 클래스 맨 위나 public 구역에 모아 두어 “이 클래스의 내부를 누가 볼 수 있는가”를 한눈에 보이게 합니다.

세 종류 중 friend 멤버 함수가 가장 좁은 공개지만 가장 까다롭습니다. C::process를 friend로 지정하려면 컴파일러가 C의 전체 정의를 이미 알고 있어야 해서, 헤더 include 순서가 조금만 어긋나도 'C' has not been declared 또는 incomplete type 'C' used in nested name specifier 에러가 납니다. 두 클래스가 서로의 헤더를 include하는 구조에서는 이 순서를 맞추기가 거의 불가능하기 때문에, 실무에서는 차라리 friend 클래스 전체를 지정하거나 설계를 바꾸는 경우가 많습니다.

friend 함수

class Point {
private:
    int x, y;
    
public:
    Point(int x, int y) : x(x), y(y) {}
    
    // friend 함수
    friend double distance(const Point& p1, const Point& p2);
    friend void print(const Point& p);
};

double distance(const Point& p1, const Point& p2) {
    int dx = p1.x - p2.x;
    int dy = p1.y - p2.y;
    return sqrt(dx * dx + dy * dy);
}

void print(const Point& p) {
    cout << "(" << p.x << ", " << p.y << ")" << endl;
}

int main() {
    Point p1(0, 0);
    Point p2(3, 4);
    
    cout << "Distance: " << distance(p1, p2) << endl;  // 5
    print(p1);  // (0, 0)
}

distance와 print는 Point 안에 선언되어 있지만 Point의 멤버가 아니므로 p1.distance(p2)가 아니라 distance(p1, p2)로 호출합니다. 두 점을 대칭적으로 받는 연산은 이 형태가 자연스럽습니다. 멤버로 만들면 한쪽은 this, 다른 쪽은 인자가 되어 “어느 점의 메서드인가”라는 어색한 질문이 생깁니다. 참고로 using namespace std; 환경에서 distance라는 이름은 std::distance(반복자 거리)와 겹칠 수 있어, 실제 코드에서는 네임스페이스에 넣거나 이름을 구체적으로 짓는 편이 안전합니다.

friend 클래스

class Engine {
private:
    int horsepower;
    
public:
    Engine(int hp) : horsepower(hp) {}
    
    // Car 클래스를 friend로 선언
    friend class Car;
};

class Car {
private:
    Engine engine;
    string model;
    
public:
    Car(const string& m, int hp) : model(m), engine(hp) {}
    
    void printInfo() {
        cout << "Model: " << model << endl;
        cout << "Horsepower: " << engine.horsepower << endl;  // private 접근
    }
};

int main() {
    Car car("Tesla", 450);
    car.printInfo();
}

friend 클래스는 한 줄로 Car의 모든 멤버 함수에 Engine의 내부 전체를 엽니다. 이 관계는 일방적입니다. Car는 Engine의 private을 볼 수 있지만 Engine은 Car의 private을 볼 수 없고, Car의 friend가 Engine 내부를 볼 수 있는 것도 아닙니다(friend는 전이되지 않습니다). 트레이드오프는 결합도입니다. Engine의 horsepower를 kilowatt로 바꾸는 순간 Car 쪽 코드도 함께 고쳐야 하므로, friend 클래스는 같은 사람이나 팀이 함께 관리하는 한 모듈 안에서만 쓰는 편이 좋습니다.

또 하나, Car 생성자의 초기화 목록은 model(m), engine(hp) 순서지만 멤버는 engine, model 순서로 선언되어 있습니다. C++은 초기화 목록의 순서가 아니라 선언 순서대로 초기화하므로, GCC·Clang에서는 -Wreorder 경고가 뜹니다. 이 예제에서는 무해하지만, 한 멤버가 다른 멤버 값을 사용해 초기화된다면 아직 초기화되지 않은 값을 읽는 버그가 됩니다.

연산자 오버로딩

class Complex {
private:
    double real, imag;
    
public:
    Complex(double r = 0, double i = 0) : real(r), imag(i) {}
    
    // friend 연산자
    friend Complex operator+(const Complex& c1, const Complex& c2);
    friend ostream& operator<<(ostream& os, const Complex& c);
};

Complex operator+(const Complex& c1, const Complex& c2) {
    return Complex(c1.real + c2.real, c1.imag + c2.imag);
}

ostream& operator<<(ostream& os, const Complex& c) {
    os << c.real;
    if (c.imag >= 0) {
        os << "+" << c.imag << "i";
    } else {
        os << c.imag << "i";
    }
    return os;
}

int main() {
    Complex c1(3, 4);
    Complex c2(1, 2);
    Complex c3 = c1 + c2;
    
    cout << c3 << endl;  // 4+6i
}

operator+를 멤버가 아닌 friend로 만든 이유는 암시적 변환 때문입니다. 생성자가 Complex(double r = 0, double i = 0)이라 double에서 Complex로 암시적 변환이 됩니다. 멤버 operator+라면 c1 + 2.0은 되지만 2.0 + c1은 컴파일되지 않습니다. 멤버 연산자의 왼쪽 피연산자에는 변환이 적용되지 않기 때문입니다. 비멤버로 두면 양쪽 모두 변환 대상이 되어 두 식이 똑같이 동작합니다. operator<<는 더 명확합니다. 왼쪽 피연산자가 ostream이라서 멤버로 만들려면 std::ostream 클래스를 수정해야 하므로, 비멤버 외에는 선택지가 없습니다.

실전 예시

예시 1: 행렬

class Matrix {
private:
    vector<vector<int>> data;
    int rows, cols;
    
public:
    Matrix(int r, int c) : rows(r), cols(c), data(r, vector<int>(c, 0)) {}
    
    friend Matrix operator*(const Matrix& m1, const Matrix& m2);
    friend ostream& operator<<(ostream& os, const Matrix& m);
};

Matrix operator*(const Matrix& m1, const Matrix& m2) {
    if (m1.cols != m2.rows) {
        throw invalid_argument("행렬 곱셈 불가");
    }
    
    Matrix result(m1.rows, m2.cols);
    
    for (int i = 0; i < m1.rows; i++) {
        for (int j = 0; j < m2.cols; j++) {
            for (int k = 0; k < m1.cols; k++) {
                result.data[i][j] += m1.data[i][k] * m2.data[k][j];
            }
        }
    }
    
    return result;
}

ostream& operator<<(ostream& os, const Matrix& m) {
    for (int i = 0; i < m.rows; i++) {
        for (int j = 0; j < m.cols; j++) {
            os << m.data[i][j] << " ";
        }
        os << endl;
    }
    return os;
}

행렬 곱셈은 두 행렬의 내부 데이터를 동시에 읽어야 하는 전형적인 경우입니다. getter로 구현하려면 at(i, j) 같은 접근자를 public으로 열어야 하는데, 그러면 모든 코드가 원소 단위로 행렬을 읽을 수 있게 됩니다. friend는 그 접근을 곱셈과 출력 두 함수로 제한합니다. 다만 행렬처럼 원소 접근이 어차피 공개 API로 필요한 타입이라면 public 접근자를 두고 friend를 없애는 쪽이 더 단순합니다. friend는 “내부 표현을 공개하고 싶지 않지만 이 연산만은 내부에 접근해야 할 때” 선택하는 도구입니다.

예시 2: 분수

class Fraction {
private:
    int numerator;
    int denominator;
    
    int gcd(int a, int b) {
        return b == 0 ? a : gcd(b, a % b);
    }
    
    void simplify() {
        int g = gcd(abs(numerator), abs(denominator));
        numerator /= g;
        denominator /= g;
    }
    
public:
    Fraction(int n = 0, int d = 1) : numerator(n), denominator(d) {
        if (d == 0) {
            throw invalid_argument("분모는 0이 될 수 없음");
        }
        simplify();
    }
    
    friend Fraction operator+(const Fraction& f1, const Fraction& f2);
    friend Fraction operator*(const Fraction& f1, const Fraction& f2);
    friend bool operator==(const Fraction& f1, const Fraction& f2);
    friend ostream& operator<<(ostream& os, const Fraction& f);
};

Fraction operator+(const Fraction& f1, const Fraction& f2) {
    int n = f1.numerator * f2.denominator + f2.numerator * f1.denominator;
    int d = f1.denominator * f2.denominator;
    return Fraction(n, d);
}

Fraction operator*(const Fraction& f1, const Fraction& f2) {
    return Fraction(f1.numerator * f2.numerator, 
                   f1.denominator * f2.denominator);
}

bool operator==(const Fraction& f1, const Fraction& f2) {
    return f1.numerator == f2.numerator && 
           f1.denominator == f2.denominator;
}

ostream& operator<<(ostream& os, const Fraction& f) {
    os << f.numerator << "/" << f.denominator;
    return os;
}

int main() {
    Fraction f1(1, 2);
    Fraction f2(1, 3);
    
    cout << f1 + f2 << endl;  // 5/6
    cout << f1 * f2 << endl;  // 1/6
}

operator==가 분자·분모를 그대로 비교할 수 있는 것은 생성자에서 항상 기약분수로 만들어 두기 때문입니다. 즉 friend 함수가 “모든 Fraction은 기약분수”라는 클래스 불변식에 기대고 있습니다. friend가 내부 표현에 결합된다는 말의 구체적인 의미가 이것입니다. 실제로 이 코드에는 빈틈이 있는데, Fraction(1, -2)와 Fraction(-1, 2)는 같은 값이지만 부호 위치가 달라 ==가 false를 반환합니다. simplify()에서 분모가 음수면 분자·분모의 부호를 함께 뒤집어 “분모는 항상 양수”라는 불변식을 추가해야 합니다. friend 함수를 여럿 두면 이런 불변식을 모든 friend가 똑같이 가정하는지 확인해야 할 곳이 늘어납니다.

예시 3: 스트림 연산자

class Person {
private:
    string name;
    int age;
    
public:
    Person(const string& n, int a) : name(n), age(a) {}
    
    friend ostream& operator<<(ostream& os, const Person& p);
    friend istream& operator>>(istream& is, Person& p);
};

ostream& operator<<(ostream& os, const Person& p) {
    os << "Name: " << p.name << ", Age: " << p.age;
    return os;
}

istream& operator>>(istream& is, Person& p) {
    cout << "이름: ";
    is >> p.name;
    cout << "나이: ";
    is >> p.age;
    return is;
}

int main() {
    Person p1("Alice", 30);
    cout << p1 << endl;
    
    Person p2("", 0);
    cin >> p2;
    cout << p2 << endl;
}

예제의 operator>> 안에서 cout으로 안내 문구를 출력하는 것은 설명용입니다. 실제 코드에서 이렇게 하면 파일 스트림(ifstream)으로 읽을 때도 콘솔에 문구가 찍히고, 연산자가 특정 스트림에 의존하게 됩니다. 입력 연산자는 읽기만 하고, 실패하면 is.setstate(std::ios::failbit)로 스트림 상태에 알리는 것이 표준 라이브러리의 관례입니다. 이 관례를 따르면 while (in >> p) 같은 반복문이 그대로 동작합니다.

friend vs getter/setter

// friend 사용
class Point {
private:
    int x, y;
    
public:
    Point(int x, int y) : x(x), y(y) {}
    
    friend double distance(const Point& p1, const Point& p2);
};

double distance(const Point& p1, const Point& p2) {
    int dx = p1.x - p2.x;  // 직접 접근
    int dy = p1.y - p2.y;
    return sqrt(dx * dx + dy * dy);
}

// getter 사용
class Point {
private:
    int x, y;
    
public:
    Point(int x, int y) : x(x), y(y) {}
    
    int getX() const { return x; }
    int getY() const { return y; }
};

double distance(const Point& p1, const Point& p2) {
    int dx = p1.getX() - p2.getX();  // getter 사용
    int dy = p1.getY() - p2.getY();
    return sqrt(dx * dx + dy * dy);
}

두 버전의 성능 차이는 없습니다. 인라인 getter는 최적화 후 직접 접근과 같은 코드가 됩니다. 차이는 공개 범위입니다. Point가 원래 좌표를 공개하는 값 타입이라면 getter(또는 아예 public 멤버를 가진 struct)가 정직한 설계이고, friend를 쓸 이유가 없습니다. 반대로 내부 표현이 바뀔 수 있는 타입, 예를 들어 좌표를 극좌표로 저장할 수도 있는 타입이라면 getter는 그 표현을 약속하는 셈이 되므로 friend로 몇 개 연산에만 여는 쪽이 변경에 유연합니다.

자주 발생하는 문제

문제 1: friend 남용

// ❌ 캡슐화 파괴
class BadClass {
private:
    int data;
    
public:
    friend class A;
    friend class B;
    friend class C;
    // 너무 많은 friend
};

// ✅ 여러 클래스가 모두 필요로 한다면, 차라리 필요한 연산을 public 인터페이스로 설계
class GoodClass {
private:
    int data;

public:
    int getData() const { return data; }
    void setData(int d) { data = d; }
};

friend 선언이 여러 개 쌓였다는 것은 대개 “이 클래스의 내부가 사실상 여러 곳에서 필요한 공개 정보”라는 신호입니다. 그 경우 friend를 늘리기보다 무엇을 공개할지 인터페이스로 정리하는 편이 낫습니다. 다만 setter를 기계적으로 여는 것도 좋은 답은 아닙니다. setData처럼 아무 값이나 넣을 수 있는 setter는 private 멤버를 public으로 바꾼 것과 거의 같으므로, deposit(amount)처럼 불변식을 검사하는 의미 있는 연산을 여는 것이 목표입니다.

문제 2: 양방향 friend

// ❌ 순환 의존
class A {
    friend class B;
};

class B {
    friend class A;
};

// ✅ 필요한 경우만 friend

두 클래스가 서로를 friend로 선언했다면 사실상 하나의 클래스가 두 파일로 나뉜 상태입니다. 어느 한쪽의 내부를 바꾸면 다른 쪽이 깨질 수 있고, 두 클래스를 따로 테스트하거나 재사용할 수 없습니다. 이런 구조가 보이면 두 클래스를 합치거나, 서로 필요로 하는 부분을 제3의 클래스로 뽑아내는 것을 먼저 검토합니다.

문제 3: friend 상속 안 됨

class Base {
private:
    int data;
    
public:
    friend void func(const Base& b);
};

class Derived : public Base {
    // func은 Derived의 friend 아님
};

friend 규칙은 세 가지로 요약됩니다. 상속되지 않고(Base의 friend는 Derived의 friend가 아님), 전이되지 않고(A의 friend인 B의 friend가 A의 friend는 아님), 대칭이 아닙니다(A가 B를 friend로 선언해도 B가 A를 friend로 선언한 것은 아님). 반대 방향도 성립합니다. 어떤 함수가 Derived의 friend라도 Base의 private 멤버에는 접근할 수 없습니다. 접근 권한은 항상 그 멤버를 선언한 클래스가 직접 준 것만 유효합니다.

실무 패턴

패턴 1: 팩토리 함수

class DatabaseConnection {
private:
    std::string connectionString_;
    bool connected_ = false;
    
    // private 생성자
    DatabaseConnection(const std::string& connStr) 
        : connectionString_(connStr) {}
    
public:
    // friend 팩토리 함수
    friend DatabaseConnection createConnection(const std::string& host, int port);
    
    void connect() {
        connected_ = true;
        std::cout << "연결됨: " << connectionString_ << '\n';
    }
};

// 팩토리 함수가 private 생성자 호출
DatabaseConnection createConnection(const std::string& host, int port) {
    std::string connStr = host + ":" + std::to_string(port);
    return DatabaseConnection(connStr);
}

// 사용
auto conn = createConnection("localhost", 5432);
conn.connect();

생성자를 private으로 막고 friend 팩토리만 열어 두면, 객체를 만드는 경로가 하나로 고정되어 연결 문자열 형식 같은 규칙을 한 곳에서 강제할 수 있습니다. 같은 목적이라면 static 멤버 함수(DatabaseConnection::create(...))가 더 흔한 선택입니다. friend 팩토리는 팩토리를 클래스 밖의 네임스페이스 함수로 두고 싶을 때(예: 여러 타입을 만드는 한 모듈의 함수) 의미가 있습니다. 주의할 점은 std::make_unique<DatabaseConnection>(...)은 private 생성자에 접근할 수 없어 컴파일 에러가 난다는 것입니다. make_unique를 friend로 선언하는 방법은 표준 라이브러리 구현에 따라 동작이 달라 권장되지 않고, 팩토리 안에서 std::unique_ptr<DatabaseConnection>(new DatabaseConnection(...))로 직접 만드는 것이 일반적입니다.

패턴 2: 비교 연산자

class Date {
private:
    int year_, month_, day_;
    
public:
    Date(int y, int m, int d) : year_(y), month_(m), day_(d) {}
    
    // friend 비교 연산자
    friend bool operator==(const Date& lhs, const Date& rhs);
    friend bool operator<(const Date& lhs, const Date& rhs);
};

bool operator==(const Date& lhs, const Date& rhs) {
    return lhs.year_ == rhs.year_ &&
           lhs.month_ == rhs.month_ &&
           lhs.day_ == rhs.day_;
}

bool operator<(const Date& lhs, const Date& rhs) {
    if (lhs.year_ != rhs.year_) return lhs.year_ < rhs.year_;
    if (lhs.month_ != rhs.month_) return lhs.month_ < rhs.month_;
    return lhs.day_ < rhs.day_;
}

// 사용
Date d1(2026, 3, 12);
Date d2(2026, 3, 13);
if (d1 < d2) {
    std::cout << "d1이 더 이른 날짜\n";
}

C++20부터는 이런 비교 연산자를 직접 쓸 일이 크게 줄었습니다. 클래스 안에 friend auto operator<=>(const Date&, const Date&) = default;와 friend bool operator==(const Date&, const Date&) = default;를 두면 컴파일러가 멤버를 선언 순서대로 비교하는 여섯 가지 비교 연산을 모두 만들어 줍니다. 위 예제는 year_, month_, day_ 순서로 선언되어 있어 기본 비교가 날짜 순서와 정확히 일치합니다. 멤버 순서를 바꾸면 비교 결과도 바뀐다는 점만 기억하면 됩니다.

패턴 3: 테스트 헬퍼

class BankAccount {
private:
    double balance_;
    std::string accountNumber_;
    
public:
    BankAccount(const std::string& accNum, double initialBalance)
        : accountNumber_(accNum), balance_(initialBalance) {}
    
    void deposit(double amount) {
        balance_ += amount;
    }
    
    // 테스트 헬퍼를 friend로 선언
    friend class BankAccountTest;
};

// 테스트 클래스
class BankAccountTest {
public:
    static void verifyBalance(const BankAccount& account, double expected) {
        if (account.balance_ == expected) {
            std::cout << "테스트 통과\n";
        } else {
            std::cout << "테스트 실패: " << account.balance_ << " != " << expected << '\n';
        }
    }
};

// 사용
BankAccount acc("123456", 1000.0);
acc.deposit(500.0);
BankAccountTest::verifyBalance(acc, 1500.0);

테스트용 friend는 편리하지만 대가가 있습니다. 운영 코드 헤더에 테스트 클래스 이름이 남고, 테스트가 private 멤버에 직접 의존하므로 내부 구현을 리팩터링할 때마다 테스트가 깨집니다. 테스트가 private 상태를 들여다봐야 한다면, 그 상태를 관찰할 수 있는 public 동작(예: balance() 조회나 거래 내역)이 빠져 있는 것은 아닌지 먼저 생각해 볼 만합니다. 참고로 double로 금액을 다루면 0.1 + 0.2 != 0.3 같은 부동소수점 오차 때문에 == 비교가 실패할 수 있으므로, 실제 금융 코드에서는 최소 단위 정수(원, 센트)나 고정소수점 타입을 씁니다.

friend 함수 vs friend 클래스: 역할 정리

구분friend 함수friend 클래스
범위선언된 그 자유 함수 하나(또는 오버로드 집합의 일부)만 접근해당 클래스의 모든 멤버 함수가 grantor의 비공개 멤버에 접근
ODR·네임스페이스클래스 안에 선언해도 클래스 멤버가 아님 — 네임스페이스 스코프에서 정의friend 클래스 자체는 여전히 별도 타입; “전체 허용”이라 변경 영향이 큼
유지보수필요한 함수만 좁게 열 수 있어 변경 범위가 작음한 번 열면 그 클래스 전체가 내부에 의존 — 결합도 상승

friend 멤버 함수(friend void Other::f(const X&);)는 “특정 타입의 특정 메서드만” 열 때 쓰며, Other 선언이 앞에 있어야 하는 등 선언 순서를 맞춰야 합니다.

연산자 오버로딩에서의 활용 (심화)

  • 대칭 이항 연산: operator+(T,T)를 비멤버로 두면 좌측·우측 변환 규칙이 자연스럽습니다. private 멤버를 읽으려면 friend 또는 public 접근자가 필요합니다.
  • 스트림 연산자 operator<<, operator>>: 첫 번째 인자가 std::ostream&이므로 멤버로 넣기 어렵고, 비멤버 + friend 선언이 관례입니다.
  • 멤버 vs 비멤버: operator+=는 보통 멤버, operator+는 비멤버 friend 조합이 흔합니다. 일관된 네이밍·예외 보장도 friend 본문 안에서 처리합니다.
class Vec {
    double x, y;
public:
    Vec(double x, double y) : x(x), y(y) {}
    friend Vec operator+(Vec a, Vec b) {
        return {a.x + b.x, a.y + b.y};
    }
};

이렇게 friend 함수를 클래스 안에서 바로 정의하는 형태를 “hidden friend”라고 부릅니다. 이 함수는 네임스페이스 스코프에 선언이 따로 없으므로 일반적인 이름 조회로는 찾을 수 없고, 인자 중 하나가 Vec일 때 ADL(인자 의존 조회)로만 찾아집니다. 덕분에 operator+ 오버로드 후보가 수백 개인 큰 코드베이스에서도 Vec과 무관한 식을 컴파일할 때는 후보에 끼지 않아 컴파일 에러 메시지가 짧아지고, 의도치 않은 암시적 변환으로 이 연산자가 선택되는 사고도 줄어듭니다. 표준 라이브러리 구현들도 비교 연산자 등에 이 기법을 사용합니다.

캡슐화와의 관계

friend는 “private를 없애는 것”이 아니라, 검증된 동료에게만 문을 여는 것에 가깝습니다.

  • 장점: 불필요한 public getter 남발을 줄이며, 불변 조건(invariant)을 깨지 않는 경로만 열 수 있습니다.
  • 단점: friend 본문은 클래스 내부 표현에 결합됩니다. grantee 코드가 바뀌면 friend 구현도 같이 수정되는 경우가 많습니다.

Effective C++ 계열에서 말하듯, 비멤버 비friend 함수가 가능하면 그쪽이 의존성이 가장 적습니다. friend는 “정말 private가 필요할 때만” 선택합니다.

실전 라이브러리·프레임워크 패턴

  • 단위 테스트: 테스트 픽스처나 friend class FooTest로 불변식만 검증하며, 프로덕션에서는 숨깁니다(남용 시 테스트 전용 로직이 프로덕션 헤더에 노출되므로 팀 규칙이 필요합니다).
  • 모듈 내부 협력자: 같은 컴포넌트 안의 detail 네임스페이스 함수만 friend로 두며, 외부 API는 좁게 유지합니다.
  • 직렬화: boost::serialization 스타일에서 serialize를 friend로 두는 패턴이 과거에 흔했습니다. 최근에는 리플렉션·코드젠 대안도 검토합니다.

남용 시 신호와 대안

  • 신호: friend 선언이 수십 줄로 늘어남, 서로 다른 팀 모듈이 서로 friend, “편해서” public API를 안 만들고 전부 friend.
  • 대안: PIMPL로 구현 세부 숨기기, 무명 네임스페이스 + 팩토리, 인터페이스 클래스로 접근 지점 최소화, C++20 모듈로 구현 단위 캡슐화.

FAQ

Q1: friend는 언제 사용해야 하나요?

A:

  • 연산자 오버로딩: operator<<, operator+ 등을 비멤버 함수로 구현
  • 헬퍼 함수: 클래스와 밀접하게 관련된 유틸리티 함수
  • 밀접한 클래스 간 협력: 두 클래스가 서로의 내부 구현을 알아야 할 때
  • 팩토리 함수: private 생성자를 호출하는 팩토리 패턴
// 연산자 오버로딩
friend std::ostream& operator<<(std::ostream& os, const MyClass& obj);

// 헬퍼 함수
friend double distance(const Point& p1, const Point& p2);

Q2: friend는 캡슐화를 파괴하나요?

A: 과도한 사용은 문제입니다. friend는 캡슐화를 제한적으로 완화하는 메커니즘이므로, 필요한 경우에만 최소한으로 사용해야 합니다.

// ❌ 남용: 너무 많은 friend
class BadClass {
private:
    int data;
    friend class A;
    friend class B;
    friend class C;
    friend class D;
};

// ✅ 적절한 사용: 필요한 경우만
class GoodClass {
private:
    int data;
    friend std::ostream& operator<<(std::ostream& os, const GoodClass& obj);
};

Q3: friend 함수는 멤버 함수인가요?

A: 아니요. friend 함수는 클래스의 멤버 함수가 아니라 독립적인 함수입니다. 클래스 내부에 선언되지만, 클래스 스코프에 속하지 않습니다.

class MyClass {
    friend void func(const MyClass& obj);  // 멤버 함수 아님
};

// 독립적인 함수
void func(const MyClass& obj) {
    // obj의 private 멤버 접근 가능
}

Q4: friend는 상속되나요?

A: 아니요. friend 관계는 상속되지 않습니다. 파생 클래스는 기반 클래스의 friend를 자동으로 상속하지 않습니다.

class Base {
private:
    int data;
    friend void func(const Base& b);
};

class Derived : public Base {
    // func은 Derived의 friend가 아님
};

void func(const Base& b) {
    // b.data 접근 가능
}

void func(const Derived& d) {
    // d.data 접근 불가 (Base의 private)
}

Q5: friend vs public getter/setter?

A:

  • friend: 선택적 접근 (특정 함수/클래스만)
  • public getter/setter: 모든 코드가 접근 가능
// friend: 특정 함수만 접근
class Box {
private:
    int width;
    friend void printWidth(const Box& box);
};

// public: 모든 코드가 접근
class Box {
public:
    int getWidth() const { return width; }
private:
    int width;
};

선택 기준:

  • 특정 함수/클래스만 접근해야 하면: friend
  • 모든 코드가 접근해야 하면: public getter/setter

Q6: friend 함수와 멤버 함수 중 어느 것을 사용해야 하나요?

A:

  • 멤버 함수: 객체의 상태를 변경하거나 객체에 밀접하게 관련된 경우
  • friend 함수: 두 객체를 대칭적으로 다루거나 연산자 오버로딩
// 멤버 함수: 객체 상태 변경
class Point {
    void move(int dx, int dy) { x += dx; y += dy; }
};

// friend 함수: 대칭적 연산
class Point {
    friend double distance(const Point& p1, const Point& p2);
};

Q7: friend 학습 리소스는?

A:

  • “Effective C++” (3rd Edition) by Scott Meyers (Item 23: Prefer non-member non-friend functions)
  • cppreference.com - Friend declaration
  • “C++ Primer” (5th Edition) by Stanley Lippman

관련 글: Operator Overloading, Access Control.

friend는 특정 함수나 클래스가 private 멤버에 접근할 수 있도록 허용하는 메커니즘입니다.


같이 보면 좋은 글