C# · 심층 가이드
C# 객체지향 완전 정리
클래스와 구조체, record의 차이를 축으로 생성자, 상속, 인터페이스, 불변 객체, 빌더, 의존성 주입까지 C# 객체지향 설계 스무 가지를 코드로 비교합니다.
객체지향이라는 단어 하나로 묶기에 C#의 타입 선택지는 꽤 넓습니다. 참조로 오가는 class, 값으로 복사되는 struct, 그리고 값 동등성과 with 식을 기본으로 갖춘 record가 각각 다른 문제를 풉니다. 자바나 파이썬에서 넘어온 사람이 가장 자주 걸리는 부분도 여기입니다. 상속 계층을 어떻게 짤지 고민하기 전에, 이 타입이 정체성으로 비교되는지 값으로 비교되는지부터 정하는 편이 순서에 맞습니다.
기본 클래스 또는 구조체와 생성자와 초기화로 뼈대를 세운 다음, 불변 데이터 객체와 값 객체 비교를 이어서 보면 record가 왜 등장했는지 자연스럽게 드러납니다. 변경 불가능 업데이트는 그 연장선에서 기존 인스턴스를 건드리지 않고 새 값을 만드는 방식이고, 반대로 생성 인자가 많아 손이 무거워질 때 빌더 패턴이 대안이 됩니다. 상속 또는 확장과 합성으로 기능 묶기는 같은 요구를 다르게 푼 짝이니 나란히 두고 읽으면 좋습니다.
record의 with는 얕은 복사입니다. 안에 리스트나 배열이 들어 있으면 새 인스턴스와 원본이 같은 컬렉션을 가리키므로, 한쪽에서 항목을 추가하면 다른 쪽에도 보입니다. 불변이라 믿고 공유하다가 생기는 버그가 대부분 여기서 나옵니다. 확장 스타일 동작도 성질을 알아 둘 만합니다. 확장 메서드는 정적 메서드를 문법적으로 바꿔 부르는 것이라, 인스턴스가 null이어도 호출 자체는 성공하고 예외는 메서드 안에서 납니다.
01기본 클래스 또는 구조체
생성자에서 Name과 Level을 받아 읽기 전용 속성에 넣고, 만든 객체의 이름을 출력합니다. 값이 생성 시점에 정해지고 이후 바뀌지 않아야 하는 도메인 모델에 이 형태를 씁니다. get만 있는 자동 속성은 백킹 필드가 readonly라 생성자 밖에서는 대입 자체가 컴파일되지 않습니다.
C# code
// 기본 클래스 또는 구조체
using System;
public class User { public User(string name, int level) { Name = name; Level = level; } public string Name { get; } public int Level { get; } }
Console.WriteLine(new User("Ada", 1).Name);get만 있는 자동 속성 대신 init 접근자를 쓰면 객체 초기화 구문으로도 값을 채울 수 있으면서 생성 이후에는 여전히 바뀌지 않습니다.
생성자에서 가상 메서드를 부르면 파생 클래스의 필드가 초기화되기 전에 그 재정의가 실행되어, 아직 null인 값을 그대로 만지게 됩니다.
02생성자와 초기화
record 한 줄이 속성, 생성자, 값 기반 비교, ToString을 한꺼번에 만들어 줍니다. 출력에는 Tags가 System.String[]으로 찍히는데, 배열은 자체 ToString이 타입 이름만 돌려주기 때문입니다.
C# code
// 생성자와 초기화
using System;
public record Profile(string Name, string[] Tags);
Console.WriteLine(new Profile("Ada", new[] { "cs" }));record의 위치 매개변수는 init 속성이 되어 객체 초기화 구문과 함께 쓸 수 있고, 값을 한 번에 꺼내는 Deconstruct도 자동으로 만들어집니다.
record가 자동 생성하는 Equals는 속성별로 비교하는데 배열 속성은 참조 비교라, 태그 내용이 같은 두 Profile이 서로 다르다고 판정됩니다.
03인스턴스 메서드
이 클래스에는 별도 메서드가 없고 속성 접근자만 인스턴스 멤버로 존재합니다. 동작을 붙이려면 Name과 Level을 읽어 판단하는 메서드를 클래스 안에 둡니다. 속성은 컴파일 시 get_Name 같은 메서드로 바뀌므로, 같은 이름의 메서드는 따로 선언할 수 없습니다.
C# code
// 인스턴스 메서드
using System;
public class User { public User(string name, int level) { Name = name; Level = level; } public string Name { get; } public int Level { get; } }
Console.WriteLine(new User("Ada", 3).Name);public bool IsSenior => Level >= 10; 같은 식 본문 속성은 읽을 때마다 계산되므로, 비용이 큰 계산이라면 속성이 아니라 메서드로 두어 호출 비용을 드러내는 편이 좋습니다.
ToString을 재정의하지 않은 클래스를 문자열에 끼워 넣으면 네임스페이스가 붙은 타입 이름만 출력됩니다. 로그에 객체를 넣었다가 아무 정보도 남지 않는 일이 잦습니다.
04정적 메서드
정적 멤버는 인스턴스 없이 타입 이름으로 부르는 쪽인데, 이 코드는 new Profile(...)로 인스턴스를 만들어 바로 출력합니다. 검증이 필요한 생성 경로는 static Profile Create(...)를 record 안에 두어 분리합니다. record의 위치 매개변수는 공개 속성이 되지만, 일반 클래스의 주 생성자 매개변수는 속성이 아니라 캡처된 값입니다.
C# code
// 정적 메서드
using System;
public record Profile(string Name, string[] Tags);
Console.WriteLine(new Profile("Ada", new[] { "cs" }));정적 팩터리 메서드는 생성자와 달리 이름을 가질 수 있고, 조건에 따라 캐시된 인스턴스를 돌려주거나 생성을 거절할 수도 있어 생성 경로가 훨씬 자유로워집니다.
정적 필드에 가변 상태를 두면 프로세스 전체가 그것을 공유하므로, 테스트끼리 값이 섞이고 여러 스레드가 동시에 접근하면 경합이 생깁니다.
05상속 또는 확장
출력에 쓰인 User는 다른 타입을 상속하지 않아 곧바로 object를 부모로 둡니다. 공통 필드를 여러 타입이 나눠 갖게 되면 이 클래스를 기반 클래스로 올립니다. 속성이 virtual이 아니라 파생 클래스에서 재정의할 수 없고, new로 가리면 기반 타입 참조로 볼 때 원래 값이 나옵니다.
C# code
// 상속 또는 확장
using System;
public class User { public User(string name, int level) { Name = name; Level = level; } public string Name { get; } public int Level { get; } }
Console.WriteLine(new User("Ada", 5).Name);재사용하려는 것이 구현뿐이라면 합성이 낫습니다. 기반 클래스는 파생 타입이 그 자리에 그대로 대체될 수 있을 때에만 의미가 있습니다.
기반 클래스에 나중에 멤버를 추가하면 이미 파생된 모든 타입의 동작이 함께 바뀔 수 있습니다. 공개된 상속 계층은 한 번 굳으면 되돌리기가 매우 어렵습니다.
06인터페이스 또는 trait
여기서는 인터페이스 없이 record 자체를 값으로 넘깁니다. 계약이 필요해지면 Name을 노출하는 인터페이스를 만들고 record가 구현하게 하면 되고, 자동 생성된 속성도 인터페이스 멤버를 그대로 만족시킵니다. 인터페이스 타입으로 받으면 값 비교는 그대로 동작하지만 with 식은 쓸 수 없습니다.
C# code
// 인터페이스 또는 trait
using System;
public record Profile(string Name, string[] Tags);
Console.WriteLine(new Profile("Ada", new[] { "cs" }));인터페이스에 기본 구현을 둘 수 있지만 이는 클래스가 구현하지 않았을 때만 쓰이고, 인터페이스 타입으로 참조할 때에만 호출됩니다.
인터페이스에 멤버를 추가하면 그것을 구현한 모든 타입이 깨집니다. 공개된 인터페이스는 좁게 유지하고 확장은 별도 인터페이스로 나누는 편이 안전합니다.
07캡슐화
필드를 직접 열지 않고 public string Name { get; }처럼 바깥에는 읽기만 허용합니다. 값이 생성 시점에 확정되는 객체라면 이 정도 노출로 충분합니다. 자동 속성의 백킹 필드는 컴파일러가 꺾쇠가 들어간 이름으로 만들기 때문에, 리플렉션이 아니면 코드에서 건드릴 방법이 없습니다.
C# code
// 캡슐화
using System;
public class User { public User(string name, int level) { Name = name; Level = level; } public string Name { get; } public int Level { get; } }
Console.WriteLine(new User("Ada", 7).Name);속성을 공개하더라도 setter만 private으로 좁히면 바깥에는 읽기만 허용하면서 클래스 안에서는 자유롭게 값을 바꿀 수 있습니다.
List<T> 속성을 읽기 전용으로 노출해도 바깥에서 Add를 부를 수 있습니다. 참조만 못 바꿀 뿐이므로 IReadOnlyList<T>로 내보내야 합니다.
08합성으로 기능 묶기
Profile은 이름과 태그 배열을 한 덩어리로 묶어 다니는 작은 데이터 조각입니다. 큰 객체가 여러 관심사를 들고 있을 때, 이렇게 쪼갠 record를 필드로 품는 편이 상속보다 조립이 자유롭습니다. 배열 속성은 record가 참조만 복사하므로 바깥에서 원소를 바꾸면 안쪽 값도 함께 바뀝니다.
C# code
// 합성으로 기능 묶기
using System;
public record Profile(string Name, string[] Tags);
Console.WriteLine(new Profile("Ada", new[] { "cs" }));record가 다른 record를 품으면 값 비교가 재귀적으로 적용되어, 안쪽 값이 모두 같으면 바깥 객체도 같다고 판정됩니다.
생성자에서 받은 배열을 그대로 저장하면 호출자가 나중에 그 배열을 고쳤을 때 객체 안의 값도 함께 바뀝니다. 방어적으로 복사해 담아야 합니다.
09팩터리 메서드
생성자를 직접 부르는 이 코드에 이름 있는 생성 경로를 더하는 것이 팩터리 메서드입니다. User.Guest()처럼 의도를 이름에 담거나 잘못된 인자를 미리 걸러야 할 때 씁니다. 생성자를 private으로 내리면 호출 경로를 강제할 수 있지만, 공개 생성자를 찾는 역직렬화기가 함께 깨질 수 있습니다.
C# code
// 팩터리 메서드
using System;
public class User { public User(string name, int level) { Name = name; Level = level; } public string Name { get; } public int Level { get; } }
Console.WriteLine(new User("Ada", 9).Name);생성자를 비공개로 두고 정적 TryCreate를 노출하면, 검증에 실패했을 때 예외 대신 false를 돌려주는 생성 경로를 만들 수 있습니다.
팩터리를 만들어 두고도 생성자를 공개해 두면 검증을 건너뛰는 경로가 그대로 남습니다. 강제하려면 생성자의 접근 수준을 함께 내려야 합니다.
10열거형 상태 표현
지금은 상태가 문자열 배열로만 표현되어 있어 오타가 나도 컴파일러가 잡아 주지 못합니다. 값의 종류가 정해져 있다면 enum으로 바꿔 태그 대신 상태 값을 담습니다. C#의 enum은 바탕이 정수라 선언에 없는 숫자도 캐스팅으로 들어갈 수 있고, 초기화하지 않은 필드는 0에 해당하는 멤버가 됩니다.
C# code
// 열거형 상태 표현
using System;
public record Profile(string Name, string[] Tags);
Console.WriteLine(new Profile("Ada", new[] { "cs" }));Enum.TryParse는 기본적으로 숫자 문자열도 받아들여 정의에 없는 값을 만들 수 있으므로, 파싱 뒤에 Enum.IsDefined로 한 번 더 확인하는 편이 안전합니다.
switch로 enum의 모든 멤버를 다뤘더라도 캐스팅으로 정의에 없는 값이 들어올 수 있습니다. 기본 가지를 두지 않으면 그 값이 어느 분기에도 걸리지 않습니다.
11불변 데이터 객체
User는 setter가 없어 생성 이후 상태가 고정됩니다. 여러 스레드가 같은 인스턴스를 공유해도 경합이 생기지 않아 설정값이나 캐시 항목으로 쓰기 좋습니다. 이 불변성은 얕은 수준이라, 속성 타입이 배열이나 리스트라면 내부 원소는 여전히 바뀔 수 있습니다.
C# code
// 불변 데이터 객체
using System;
public class User { public User(string name, int level) { Name = name; Level = level; } public string Name { get; } public int Level { get; } }
Console.WriteLine(new User("Ada", 11).Name);불변 객체는 해시 코드가 변하지 않으므로 Dictionary의 키나 HashSet의 원소로 안심하고 쓸 수 있습니다.
속성 타입을 IReadOnlyList<T>로 선언해도 원본이 List<T>라면 그 참조를 가진 쪽이 여전히 내용을 바꿀 수 있습니다. 진짜 고정이 필요하면 불변 컬렉션을 써야 합니다.
12빌더 패턴
record의 위치 생성자는 인자 순서를 외워야 하고, 항목이 늘수록 호출부를 읽기 어려워집니다. 선택 항목이 많은 객체라면 값을 단계별로 채우는 빌더나 with 체인으로 옮깁니다. with는 새 인스턴스를 만들어 주지만 얕은 복사라 Tags 배열은 원본과 같은 것을 가리킵니다.
C# code
// 빌더 패턴
using System;
public record Profile(string Name, string[] Tags);
Console.WriteLine(new Profile("Ada", new[] { "cs" }));with 식은 복사 생성자와 init 접근자를 통해 동작하므로, 속성이 init 없이 읽기 전용이라면 그 속성은 with로 바꿀 수 없습니다.
빌더 인스턴스를 재사용하면 앞서 만든 객체의 설정이 다음 객체에 그대로 남습니다. 빌더는 결과를 만들 때마다 새로 만들거나 내부 상태를 비워야 합니다.
13다형성
출력되는 Name은 재정의 없이 클래스에 직접 선언된 속성입니다. 호출 지점을 그대로 두고 동작만 갈아 끼우려면 멤버를 virtual로 열거나 인터페이스를 앞에 세워야 합니다. C#은 메서드가 기본적으로 비가상이라 virtual과 override를 둘 다 적지 않으면 재정의가 아니라 숨기기가 됩니다.
C# code
// 다형성
using System;
public class User { public User(string name, int level) { Name = name; Level = level; } public string Name { get; } public int Level { get; } }
Console.WriteLine(new User("Ada", 13).Name);더 이상 재정의될 일이 없는 멤버나 타입에 sealed를 붙이면 의도를 분명히 드러낼 수 있고, 런타임이 호출을 직접 연결할 여지도 생깁니다.
기반 타입 참조로 new로 가린 멤버를 부르면 기반 쪽 구현이 실행됩니다. 재정의한 줄 알고 override를 빠뜨리면 호출 경로에 따라 동작이 달라집니다.
14의존성 주입
new Profile(...)처럼 호출부가 직접 생성하면 그 자리에서 구현이 못 박힙니다. 협력 객체를 생성자 인자로 받게 바꾸면 조립은 컨테이너가 맡고 테스트에서는 가짜 구현을 넣을 수 있습니다. 수명이 다른 서비스를 잘못 엮으면 싱글턴이 스코프 서비스를 붙잡아 요청이 끝나도 놓지 않는 캡티브 의존성이 생깁니다.
C# code
// 의존성 주입
using System;
public record Profile(string Name, string[] Tags);
Console.WriteLine(new Profile("Ada", new[] { "cs" }));생성자 주입은 필요한 협력 객체를 시그니처에 그대로 드러내므로, 인자가 지나치게 많아지면 그 타입이 책임을 너무 많이 지고 있다는 신호로 읽을 수 있습니다.
싱글턴 서비스의 생성자에 스코프 수명인 서비스를 주입하면 첫 요청의 인스턴스가 앱이 끝날 때까지 붙잡혀, 이미 정리된 객체를 계속 쓰게 됩니다.
15값 객체 비교
User는 클래스라서 손대지 않으면 참조가 같을 때만 같다고 판정합니다. 이름과 레벨이 같으면 같은 값으로 보고 싶다면 record로 바꾸거나 Equals와 GetHashCode를 함께 재정의합니다. 둘 중 하나만 고치면 Dictionary 키로 넣은 값을 나중에 찾지 못하게 됩니다.
C# code
// 값 객체 비교
using System;
public class User { public User(string name, int level) { Name = name; Level = level; } public string Name { get; } public int Level { get; } }
Console.WriteLine(new User("Ada", 15).Name);record로 바꾸면 Equals와 GetHashCode, ==가 함께 값 비교로 생성되지만, 배열이나 리스트 속성은 여전히 참조로 비교된다는 점은 그대로 남습니다.
GetHashCode를 가변 속성으로 계산하면 사전에 넣은 뒤 값이 바뀔 때 그 항목을 다시 찾을 수 없습니다. 해시에는 변하지 않는 값만 넣어야 합니다.
16저장소 객체
Profile 같은 모양이 저장소가 돌려주는 결과 타입이 됩니다. 조회와 저장을 맡는 타입을 따로 두면 호출부는 SQL이나 파일 경로를 몰라도 됩니다. 저장소가 IEnumerable을 그대로 반환하면 호출부가 두 번 훑을 때 질의도 두 번 나가므로, 목록은 리스트나 배열로 확정해 돌려줍니다.
C# code
// 저장소 객체
using System;
public record Profile(string Name, string[] Tags);
Console.WriteLine(new Profile("Ada", new[] { "cs" }));저장소 메서드가 비동기라면 이름을 GetByIdAsync처럼 맞추고 취소 토큰 매개변수를 두어, 호출부가 요청 취소를 그대로 아래로 전달할 수 있게 해야 합니다.
저장소가 캐시에 있는 객체를 그대로 돌려주면 호출부가 그것을 고쳤을 때 캐시 내용까지 바뀝니다. 공유 캐시에서 꺼낸 값은 복사하거나 불변으로 두어야 합니다.
17서비스 객체
데이터만 든 User와 달리 서비스는 이런 값을 받아 규칙을 실행하는 쪽입니다. 승급 판정처럼 여러 객체가 얽히는 로직을 모델 밖으로 뺄 때 만듭니다. 서비스가 상태를 필드로 들고 있으면 싱글턴으로 등록했을 때 요청끼리 그 값을 공유하게 되므로, 무상태로 두는 편이 안전합니다.
C# code
// 서비스 객체
using System;
public class User { public User(string name, int level) { Name = name; Level = level; } public string Name { get; } public int Level { get; } }
Console.WriteLine(new User("Ada", 17).Name);서비스가 무상태라면 싱글턴으로 등록해도 안전하고 요청마다 만드는 비용도 사라집니다. 상태가 필요하다면 그 값을 메서드 인자로 넘기는 편이 낫습니다.
서비스 안에서 DateTime.Now나 Guid.NewGuid를 직접 부르면 결과가 매번 달라져 테스트를 고정할 수 없습니다. 이런 값은 주입받아 쓰는 편이 좋습니다.
18DTO 매핑
Profile은 이름과 태그만 담아 계층 경계를 넘기기 좋은 모양입니다. 내부 도메인 타입을 그대로 API 응답에 쓰면 필드 하나를 바꿀 때 외부 계약까지 깨지므로 전용 타입으로 옮겨 담습니다. 매핑에서 배열을 그대로 넘기면 응답 객체와 도메인 객체가 같은 배열을 공유하니 복사해서 넣습니다.
C# code
// DTO 매핑
using System;
public record Profile(string Name, string[] Tags);
Console.WriteLine(new Profile("Ada", new[] { "cs" }));DTO는 직렬화 규칙에 맞춰 이름과 형식을 정하는 자리이므로, 도메인 타입에 직렬화 특성을 다는 대신 여기에 모아 두면 계층이 서로 섞이지 않습니다.
도메인 타입을 그대로 응답으로 내보내면 나중에 필드를 하나 추가했을 때 그 값이 자동으로 외부에 공개됩니다. 노출하면 안 되는 값이 섞이는 사고가 이렇게 납니다.
19변경 불가능 업데이트
속성에 setter가 없어 레벨을 올리려면 새 User를 만들어야 합니다. 변경 전후를 비교하거나 이력을 남겨야 하는 흐름에서 이 방식이 편합니다. 일반 클래스에는 with 식을 쓸 수 없고, record로 선언한 타입이나 구조체에서만 컴파일러가 복사 경로를 만들어 줍니다.
C# code
// 변경 불가능 업데이트
using System;
public class User { public User(string name, int level) { Name = name; Level = level; } public string Name { get; } public int Level { get; } }
Console.WriteLine(new User("Ada", 19).Name);값이 바뀔 때마다 새 인스턴스를 만들면 이전 값을 그대로 들고 있을 수 있어, 변경 전후 비교나 되돌리기를 별도 이력 구조 없이 처리할 수 있습니다.
복사해 새로 만드는 방식은 속성이 늘어날 때마다 옮겨 적을 코드가 늘고, 새 속성을 추가하며 복사에서 빠뜨리면 그 값만 조용히 기본값으로 돌아갑니다.
20확장 스타일 동작
record를 고치지 않고 동작을 붙이려면 정적 클래스에 this Profile profile을 첫 인자로 받는 확장 메서드를 둡니다. 남의 라이브러리 타입이나 봉인된 타입에 편의 기능을 얹을 때 이 방법을 씁니다. 확장 메서드는 private 멤버에 닿지 못하고, 같은 이름의 인스턴스 메서드가 있으면 언제나 그쪽이 먼저 선택됩니다.
C# code
// 확장 스타일 동작
using System;
public record Profile(string Name, string[] Tags);
Console.WriteLine(new Profile("Ada", new[] { "cs" }));확장 메서드는 인스턴스가 null이어도 호출되므로, 안에서 null을 처리하면 NullReferenceException 없이 동작하는 도우미를 만들 수 있습니다.
확장 메서드는 네임스페이스를 열어야 보이므로, 같은 이름의 확장이 여러 네임스페이스에 있으면 어느 쪽인지 모호해져 컴파일 오류가 납니다.
정리하며
- 타입을 고를 때는 상속 구조보다 정체성 비교인지 값 비교인지를 먼저 정합니다
- record의
with는 얕은 복사라 내부 컬렉션은 원본과 계속 공유됩니다 - 확장 메서드는 정적 호출이라
null인스턴스에서도 호출 자체는 성공합니다 - 생성자 인자가 늘어나면 기본값 남발 대신 빌더나 설정 객체로 옮겨 담습니다
더 깊이 들어가고 싶다면 C# 학습 라이브러리에서 다른 주제 가이드를 이어서 보거나, 언어 비교에서 같은 개념이 다른 언어에서 어떻게 표현되는지 확인해 보세요.