개발에 관해
이제 곧 개발을 해온 시간이 어느덧 3년이 되어갑니다.
유니티 클라이언트 개발자로서 다양한 프로젝트를 진행하면서, 최근의 개발 방식이 이전과 달라졌다는 점을 깨달았습니다.
특히 신입 시절과 비교하면, 제 개발방식이 크게 변화했음을 느낍니다.
이번 개발일지에서는 최근 몇 주 동안 겪었던 개발 관련 이슈들에 대해 제 개인적인 견해를 소소하게 적어보려고 합니다.
오버 엔지니어링
최근 들어 가장 크게 느끼는 점은 오버 엔지니어링에 대한 제 인식이 변화하고 있다는 것입니다.
처음 개발을 배울 때는 오버 엔지니어링이 무조건 나쁘다고 생각했습니다.
하지만 최근 제 개발 방식을 돌아보니, 어느 정도의 오버 엔지니어링을 하면서 개발하고 있음을 깨달았습니다.
제가 생각하는 오버 엔지니어링은 확장성과 과도한 설계 사이의 중간?이라고 봅니다.
클라이언트 개발자가 오버엔지니어링에 빠지는 주된 이유는 과도한 확장성 추구 때문이라고 생각합니다.
그럼 우선 기본적으로 확장성 있는 코딩 설계가 어떤 것인지 살펴보도록 합시다.
확장성 있는 코딩 설계
예를 들어, 확장성 있는 개발이란 오크의 부모 몬스터 클래스를 만들 때 상속 가능한 부모 타입으로 설계하여, 추후 다른 몬스터를 쉽게 개발할 수 있도록 하는 것입니다.
이는 개발의 효율성과 유지보수성을 높이는 것이 목적입니다.
public abstract class Monster
{
protected float health;
protected float attackPower;
// 몬스터의 기본 스탯을 초기화하는 메소드
protected abstract void InitializeStats();
// 몬스터가 공격하는 기능
public abstract void Attack();
}
// Monster 클래스를 상속받는 Orc 클래스
public class Orc : Monster
{
// 추상 클래스에서 선언된 메소드 구현
protected override void InitializeStats()
{
health = 150.0f; // 오크의 기본 체력
attackPower = 25.0f; // 오크의 공격력
}
// 공격 메소드 구현
public override void Attack()
{
Debug.Log("Orc attack with club!");
}
}
C#
복사
이와 같이 추후 다른 몬스터 타입 클래스를 위해 확장성 있게 설계하는 것이 확장성과 유지보수성을 고려한 기본적인 코딩 설계라고 생각합니다.
과도한 오버 엔지니어링
그렇다면, 과도한 오버 엔지니어링은 어떤 모습일까요?
제가 생각하는 과도한 오버 엔지니어링의 첫 번째 예는 기획자의 의견 없이, 개발자 개인이 "이런 기능이 나중에 필요할 것 같다"고 판단하여 추가하는 기능입니다.
public abstract class Monster
{
protected float health;
protected float attackPower;
// 몬스터의 기본 스탯을 초기화하는 메소드
protected abstract void InitializeStats();
// 몬스터가 공격하는 기능
public abstract void Attack();
// 몬스터가 방어하는 기능
// 기획서에 없지만 개발자가 생각하기에 추후 추가될 것 같은 기능
public abstract void Defence();
}
C#
복사
이러한 방식으로, 기획서에 명시되지 않았지만 개발자가 "Defence" 기능이 향후 Monster에 추가될 것이라고 예상하여 미리 구현해 놓는 것이 과도한 오버엔지니어링의 전형적인 예시입니다.
이 예시는 다소 극단적으로 설명되었을 수 있지만, 실제 개발 과정에서 이와 유사한 상황들이 빈번히 발생합니다.
오버 엔지니어링
이제 제가 생각하는 적절한 오버 엔지니어링에 대해 설명하겠습니다.
하나의 예시를 들어보겠습니다.
기획서에는 몬스터의 공격 방식이 단 두 가지로 정의되어 있습니다.
첫 번째는 근거리 공격입니다.
두 번째는 원거리 공격입니다.
그래서 클라이언트는 공격에 대해 아래와 같이 클래스를 작성했습니다.
public abstact class Attack
{
// isClose가 True면 근거리, False면 원거리
public bool isClose = false
}
C#
복사
이러한 방식으로 작성하면, 추후 근거리나 원거리가 아닌 새로운 공격 유형이 추가될 경우 Attack이라는 부모 추상 클래스를 수정해야 하는 상황이 발생합니다.
그렇기에 저는 아래와 같이 작업을 하고있습니다.
public abstact class Attack
{
public AttackType attackType
}
public enum AttacType
{
None,
CloseAttack,
RangeAttack,
}
C#
복사
이렇게 작성하면 bool 형식으로 작성했을 때보다 클래스 크기가 3바이트 정도 더 커집니다.
bool은 1바이트인 반면, enum 형식은 4바이트를 차지하기 때문입니다.
하지만 이러한 방식으로 개발하면 훨씬 더 직관적이고, 추후 다른 공격 타입이 추가되었을 때 유연하게 대처할 수 있습니다.
즉, AttackType은 현재 요구되는 역할을 수행하면서도 추후 확장될 수 있는 유연한 구조로 설계된 것입니다.
저는 이것이 적절한 오버엔지니어링의 핵심이라고 생각합니다.
첫째, 직관성
둘째, 확장성
셋째, 기능성
이 세 가지를 확실히 갖추었다면, 성능 면에서 미세한 차이가 있더라도 후자의 방식을 선택하는 것이 중요합니다.
즉, 코드의 미세한 성능 차이보다 개발의 편의성을 우선시하는 것이 현대 개발 방식에 더 적합합니다.
과거 마리오 게임처럼 몇 KB 내에서 모든 것을 해결해야 했던 시절에는 이런 오버엔지니어링이 적절하지 않았을 것입니다.
하지만 현대의 하드웨어는 4~8GB 정도의 메모리를 쉽게 다룰 수 있기 때문에, 몇 바이트 정도의 차이보다는 개발의 편의성을 고려하는 것이 더 중요합니다.
Nullable
C# 7.0에서는 기존의 struct 타입 형식에 null 값을 허용하는 새로운 기능이 추가되었습니다.
이는 int?나 long?과 같은 형태로 사용됩니다.
이 기능을 자세히 살펴보던 중, 이를 효과적으로 활용할 수 있는 다양한 방법이 존재한다는 사실을 깨달았습니다.
우선, null을 허용한다고 했지만 실제로 null이 저장되는 것은 아닙니다.
그러나 Object.ReferenceEquals를 사용하여 null과 비교할 때 True를 반환합니다.
즉, 겉으로는 가짜지만 실질적으로는 진짜 null처럼 동작할 수 있습니다.
예를 들어, 타일의 포지션을 x,y 좌표를 나타낸다고 합시다.
public class Tile
{
public int PosX;
public int PosY;
}
C#
복사
일반적으로 위와 같이 선언하는 것이 보편적인 방법입니다.
하지만 이 방식에는 한 가지 문제가 있습니다. 타일의 x, y 좌표가 초기화되지 않았는지 판단하기 어렵다는 점입니다. 왜냐하면 0, 0이 실제로 초기화된 유효한 좌표일 수도 있기 때문입니다.
그래서, 조금 더 나은 방식으로 아래와 같이 초기 값을 선언하기도 합니다.
public class Tile
{
private const int DefaultPos = -10000;
public int PosX = DefaultPos;
public int PosY = DefaultPos;
}
C#
복사
이렇게 선언하면 DefaultPos와 비교하여 타일이 초기화되었는지 쉽게 판단할 수 있습니다.
하지만 이 방식에도 문제가 있습니다. 만약 타일의 계산 공식이 실수로 이 DefaultPos 값을 사용했는데도 우연히 올바른 결과가 나온다면, 오류를 감지하기 어려워집니다.
public int TilePosMultiply(int a, int b)
{
// 원래라면, 이 부분에서 a,b의 값이 정상적인지 확인하는 값이 있어야되지만, 실수로 인해서 그 부분이 없다고 가정
return a*b;
}
C#
복사
이 때, int? 타입을 사용하면 매우 유용하게 사용할 수 있습니다.
public int? TilePosMultiply(int? a, int? b)
{
return a*b;
}
C#
복사
이런 방식으로 계산할 경우, a나 b 중 하나라도 초기화되지 않았거나 값이 유효하지 않으면 계산 결과는 null이 됩니다.
이는 오류를 쉽게 감지할 수 있게 해주는 큰 장점입니다.
Gaurd Cluase, Early Exit
저는 개인적으로 코드의 직관성을 가장 중요하게 여깁니다. 그 다음으로 코드의 성능을 고려합니다.
코드의 직관성을 높이기 위한 효과적인 방법 중 하나는 Guard Clause 또는 Early Exit이라고 불리는 조기 종료 분기문입니다.
개발 경험상, 코드의 복잡성은 주로 조건 분기문에서 발생한다는 점을 깨달았습니다.
예를 들어 아래와 같은 코드가 있다고 합시다.
public void Check(classA a, classB b, classC c)
{
if(a != null)
{
if(b != null)
{
if(c != null)
{
//여러 로직
}
}
}
}
C#
복사
이렇게 if문이 중첩되어 늘어나면, 코드의 가독성이 크게 떨어질 수 있습니다.
그래서 아래와 같이 조기 종료를 통해, 좀더 나은 코드를 작성할 수 있죠.
public void Check(classA a, classB b, classC c)
{
if(a == null || b == null || c == null) return;
// 여러 로직
}
C#
복사
이처럼 작성하는 것이 훨씬 간결합니다.
더불어, 위의 if문마저도 과도하게 복잡할 수 있습니다.
따라서 개발자의 판단에 따라 아래와 같이 더욱 간결하고 명확한 코드를 작성하는 것이 조기 종료의 핵심입니다.
public void Check(classA a, classB b, classC c)
{
if(a == null) return;
if(b == null) return;
if(c == null) return;
}
C#
복사