가을의 개발 일지

“프로그래밍 언어도 언어다.” - 코드 가독성을 높이려면 본문

개발론

“프로그래밍 언어도 언어다.” - 코드 가독성을 높이려면

가을고양이 2025. 7. 3. 18:19

서론

PPT와 함께 발표했던 자료. 이제서야 블로그에 쓸 생각이 났다.

 

5월 9일, 개모임에서 발표했던 내용에 몇 가지를 덧붙여, 최근 코드를 짜며 든 생각을 정리한 글이다.

개모임은 부경대학교 프로젝트 동아리 WAP의 내부 행사로, 발표자들이 원하는 주제를 가지고 10~20분간 짧은 발표를 하고, Q&A를 진행하는 일종의 세미나다.

 

그래서 이게 무슨 주제인가?하면 코드 가독성을 높이는 방법에 관한 글이다. 클린 코드와의 차이점이 있다면, 클린 코드가 인수 수, 함수/변수명의 길이, if 뎁스, KISS, YAGNI 등 코드의 구조에서 깔끔하게 쓰는 법을 연구한다면, 이 글은 영어 문장으로서의 가독성에 중점을 두고 작성했다는 점이다.

 

코드 역시 글이다. 코드 작성 시 어떤 목표를 가지고 작성해야 하며, 어떤 방법을 사용하면 가독성을 높일 수 있는지. 다년간의 코드 작성 경험 및 사색을 바탕으로, 최근 내가 사용 중인 방법들을 정리해 보겠다.

 


 

목표

먼저, 읽기 편한 코드를 짜려면 어떤 목표를 가져야 할까? 가독성이 높은 코드는 그 자체로도 훌륭한 목표지만, 지나치게 추상적이고 거대한 감이 있다.

 

나는 여기서, 영어 문장과 다를 바 없는 코드를 가독성이 좋은 코드로 설정했다. 즉, C든 JAVA든 C#이든, 영어 지문을 읽듯 술술 읽혀야 좋은 코드라는 것이다. 이는 나아가, 해당 언어의 문법을 모르더라도 이해에 어려움이 없는 단계까지 이어질 수 있다.

 

그럼 어떻게 그걸 구현할 수 있는데?

나는 5가지 방법을 코딩에 적용하고 있다. 광범위한 것부터 세세한 것 순서로, 지금부터 하나씩 살펴보겠다.

 

1. 클래스의 역할은 명확해야 한다.

가장 넓은 범위이자 가장 기초는, 클래스별 역할의 명확성이다.

우리가 아무리 문장을 잘 쓰고 단어 선택을 잘한다 한들, 주제가 어긋나면 그 글은 이해할 수 없는 글이 된다.

 

예를 들면 다음과 같은 상황이다.

public class PlatformerPlayer : Monobehaviour {
    private void Move();
    private void Jump();
    private void Attack();
    private void GetScore(int score);
}

 

흔히 볼 수 있는 플랫포머 게임의 Player 클래스다. 잡다한 부분들을 전부 지우고 함수부만 남겼음에도, 이 클래스의 역할을 제대로 파악하긴 힘들다. (해봐야 플레이어를 조종하는 함수구나. 정도다.)

 

만약 여기서 더블 점프, 무기 획득, 무적, 벽타기 등의 기능이 추가되면 어떨까? 하나의 클래스에 점점 많은 역할이 덕지덕지 붙게 될 거고, 코드가 1000줄을 넘어가는 것도 우스워질 것이다. 이는 클래스를 하나의 객체로 보고 코딩할 때 자주 나타나는 실수다.

이렇게 거대해진 단일 클래스는 켜는 순간부터 숨이 막혀온다. 모든 줄에 주석이 달려있고 함수 분리를 아무리 잘해도, 사람이 받아들이기엔 너무 많은 정보가 담겨 있는 것이다.

 

우리가 자기소개서 중 소개 부분을 작성하는데, 갑자기 친구의 외모, 반려견의 건강 상태, 아르바이트 중 있던 힘든 점이 들어간다면 어떻겠는가? 모두 나와 어느 정도 관련이 있으나, 주제에 전혀 맞지 않는 내용들이다. 이런 문장이 섞이는 순간, 주제는 흐려지고 흐름은 뒤죽박죽이 된다.

 

따라서, 가독성을 위해 가장 먼저 해야 할 것은 클래스를 기능 단위로 잘 분리하는 것이다. 예를 들어 위의 코드는 다음과 같이 쪼갤 수 있다.

 

public class InputPlatformerMover : Monobehaviour {
    private void Move();
    private void Jump();
}

public class InputAttacker() {
    [SerializedField] private KeyCode attackKeyCode;	// 가볍게 구 InputSystem을 써보자.
    
    // 오브젝트에 공격 기능을 추가한다.
}

public class ScoreGetter() {
    // 죽었을 때 싱글톤 GameData에 접근해 스코어를 올린다.
}

 

PlatformerPlayer 클래스를 플랫포머식 이동 기능, 공격 기능, 점수 획득 기능으로 분리했다. 그리고 이들을 컴포넌트로 부착해, 하나의 Player를 개발할 수 있다.

 

이렇게 분리된 클래스는 다음 프로젝트에도 그대로 사용 가능하단 장점도 있지만, 그보다도 가독성 자체가 올라간다. 우리가 InputPlatformerMover를 이해하면서, 동시에 ScoreGetter의 내용을 읽을 필요가 없기 때문이다.

 

실제로 책을 읽을 때도, 단원의 제목과 본문의 내용이 일치하면 책이 술술 읽히는 경험이 있을 것이다. 문단이 길어져 흐름을 놓치더라도, 단원의 주제를 상기시킨 채 읽고 있었기에 다시 흐름을 되찾을 수 있다. 그러니, 어떤 글이든 코드든, 가장 중요한 건 주제가 명확하고 구체적이며, 관련 없는 얘기는 없애는 것이다.

 


 

2. 생명 주기 함수는 흐름 파악이 쉬워야 한다.

두 번째는 가장 유용하게 적용 중이자 핵심으로, 코드 내의 흐름에 관한 이야기다.

 

1에서 적절히 클래스를 분리했다면, 이번엔 그 클래스 내부를 채워야 한다. 여기엔 변수, 생명 주기 함수, 커스텀 함수가 포함된다.

(여기서 생명 주기란 유니티의 표현을 빌린 것으로, 이름이 정해져 있고, 특정 시점에 호출되는 함수들을 뜻한다. 아두이노의 loop, C의 main 등이 여기 속한다.)

 

이번 장은 그중 생명 주기 함수에 관한 내용이다. 먼저, 간단한 예시를 하나 살펴보자.

public class PlatformerMover : Monobehaviour
{
    public void Update()
    {
    	// A를 누르면 왼쪽으로 이동한다.
    	if(Input.GetKeyDown(KeyCode.A))
        {
            transform.position += new Vector3(1, 0, 0);
        }
        
        // D를 누르면 오른쪽으로 이동한다.
    	if(Input.GetKeyDown(KeyCode.D))
        {
            transform.position += new Vector3(-1, 0, 0);
        }
        
        // Z를 눌렀을 때, 점프가 가능하다면 점프한다.
    	if(Input.GetKeyDown(KeyCode.Z) && jumpCount > 0)
        {
            rigidbody.AddForce(Vector3.up);
            jumpCount--;
        }
        
        // 땅에 닿으면 점프 가능 횟수를 전부 회복한다.
    	if(Physics.Raycast(transform.position, Vector3.down, out hit, MaxDistance))
        {
            jumpCount = 2;
        }
    }
}

 

(New Input System을 사용하면 더 좋은 코드를 작성할 수 있으나, 여기선 해당 사항은 무시하겠다.)

 

이 코드는 2D 플랫포머 게임에서 이동을 담당하는 클래스로, 좌우 이동과 점프에 대해 다루고 있다.

찬찬히 주석과 함께 읽으면 이해하는 데 어려움은 없지만, 그건 어디까지나 내용이 적어서일 뿐. Update 함수가 길어진다면 읽기도 전에 숨이 턱 막힐 것이다. 내용이 전부 풀어져 있어, 마치 수능 영어 지문을 보는 듯하다.

 

이런 상황을 해결하려, 이렇게 코드를 짜는 사람도 있을 것이다.

public class PlatformerMover : Monobehaviour
{
    public void Update()
    {
    	Move();
        Jump();
        ResetJumpCount();
    }
    
    private void Move() { }
    private void Jump() { }
    private void ResetJumpCount() { }
}

 

하지만 이 역시 좋은 코드는 아니다. Update의 길이는 짧아졌지만, 이번엔 흐름을 파악할 수 없다.

 

Update 함수는 매 프레임, 1번씩 실행되는 함수다. 위 코드를 문자 그대로 이해하면, 매 프레임마다 이동, 점프, 점프 횟수 초기화를 한 번씩 실행한다는 의미가 된다.

 

어떤 경우에 실행되는지, 실행되지 않는 경우도 있는지 등, 기존에 있던 if, for문 등이 함수 안으로 숨어버렸다. 이에 의미가 왜곡되어 버렸다. 즉, 무작정 함수로 분리하기만 하는 것 역시 좋은 방법은 아니다.

 

그렇다면, 어떻게 코드를 작성해야 할까?

 

public class PlatformerMover : Monobehaviour
{
    public void Update()
    {
    	if(IsPressing(KeyCode.A))
        {
            MoveLeft();
        }
        
    	if(IsPressing(KeyCode.D))
        {
            MoveRight();
        }
        
    	if(IsPressing(KeyCode.Z) && CanJump())
        {
            Jump();
        }
        
    	if(IsGrounded())
        {
            ResetJumpCount();
        }
    }
    
    private void Move() { }
    private void Jump() { }
    private void ResetJumpCount() { }
    
    private bool IsPressing(KeyCode key) { }
    private bool CanJump() { }
    private bool IsGrounded() { }
}

 

흐름 제어 구문은 유지한 채, 조건문에 들어갈 내용과 실행부를 각각 함수화하는 것이다. 이렇게 하면 코드의 이해가 몇 배는 빨라진다.

 

if is Grounded, Reset jump count. 이는 단독으로 놓고 보아도 평범한 영어 문장과 비슷하다. if문 뒤에 주어가 없고, 관사가 생략된 정도와 대소문자만 고치면 올바른 문장도 된다. (if player is Grounded, reset the jump count.)

 

이 문장이 Update 함수 내에 있으니, 그 의미를 아는 사람이라면 '매 프레임마다, 땅에 붙어 있다면 점프 횟수를 초기화한다.'고 쉽게 이해할 수 있다. 이 정도까지 왔다면, 주석이 없어도 의미 파악이 어렵지 않을 것이다.

 

핵심만 정리해 본다면

  1. if, while, for 등 흐름을 관리하는 구문은 그대로 작성한다.
  2. 위 구문들의 조건을 따로 함수로 만들고, 적절한 이름을 붙인다.
  3. 조건에 따라 실행할 일들도 따로 함수로 만들고, 적절한 이름을 붙인다.

가 된다.

 

그러나, 이 방법엔 몇 가지 주의 사항이 있다.

 


 

함수의 개수가 늘어난다.

이는 각종 디자인 패턴에서, 클래스의 개수가 늘어나는 것과 유사하다. 하지만 난 이것을 단점으로 보지 않는다.

 

우리가 유니티 내장 컴포넌트를 사용할 때, 함수의 원문을 읽는 일이 얼마나 되던가? AddForce, GetKeyDown, 나아가 Vector3의 생성자조차, 우린 F12로 열어볼 일이 많지 않다. 그리고, 몰라도 사용하는 데 문제가 없다.

 

우리가 작성할 코드 역시 같은 흐름을 가져야 한다고 생각한다. 비록 함수의 개수는 늘어나지만, 우린 그 함수들을 열어볼 일이 없어야 한다. 그렇게만 할 수 있다면, 코드 길이가 이전에 비해 증가했더라도, 실제로 읽어야 할 코드의 수는 더 줄어들게 된다. 그렇기에 이를 무작정 단점으로 취급하진 않는다.

 

오히려, 다음이 더 중요하다.

 

어디까지 함수화할 것인지 기준을 세워야 한다.

함수를 늘리는 것 자체는 두려워할 필요 없다. 그러나, 때론 함수화하지 않는 게 가독성이 높은 경우도 있음은 명심해야 한다.

 

예를 들어, 다음과 같은 코드가 있다고 하자.

public void Start()
{
    if(rigidbody == null)
    {
    	rigidbody = GetComponent<Rigidbody>();
    }
}

 

Rigidbody 컴포넌트에 대한 null 체크 구문이다. 이 구문에도 위 방법을 적용하면, 아래처럼 변한다.

public void Start()
{
    if(IsNull(rigidbody))
    {
    	AssignMyComponentToComponent(Rigidbody);
    }
}

 

함수로 변환해 주더라도, 이전보다 가독성이 좋아지지 않았다. 위처럼 짧은 조건문, 혹은 실행부가 있다면, 때론 그대로 놔두는 게 더 나을 때도 있다.

 

예를 들어, 나는 이 방법의 예시로 Input.GetKeyDown(KeyCode.A)로 함수로 분리하고, 이름을 바꿨다. 하지만, 이 역시 가독성을 해치는 코드라고 보긴 어렵다. 즉, 그냥 사용해도 아무 문제가 없었다는 것이다.

 

어디부터 함수로 분리할지, 그 기준은 사람마다 다 다르다. 그러니 코딩을 해나가며, 본인만의 기준을 세워야 한다. 타입 검사, Input 클래스의 조건문 등, 어느 정도 수준에서 함수로 분리하는 게 좋은지는 직접 경험해보는 것이 낫다.

 


 

3. 커스텀 함수의 내부는 가독성이 핵심이 아니다.

위 방법은 꽤 좋아 보인다. 그런데, 왜 생명 주기 함수만 따로 말했을까?

해당 방법은 커스텀 함수에도 적용할 수 있지만, 그건 보통 주객전도인 상황이 많기 때문이다.

 

커스텀 함수는, 개발자가 원할 때 호출해 사용하는 함수들이다. 가독성의 측면에서, 커스텀 함수의 목적은 무엇인가? 해당 함수가 실행되는 곳에서, 함수 내부를 보지 않고도 의미를 파악하기 위해서다. 그렇기에 우린 함수명을 짓는데 엄청난 공을 들인다.

 

즉, 커스텀 함수의 내부는 보지 않을 때 가장 효과적이다. 그런데, 생명 주기 때와 같이 새 함수를 만들어 함수의 개수를 늘린다면, 오히려 중요한 함수와 그렇지 않은 함수가 뒤섞여 난장판이 될 것이다.

 

따라서, 작은 규모의 함수는 건드리지 않는다. 건드린다 해도, 코드 순서를 바꾸고, 변수명이나 함수명을 바꾸는 정도에서 그쳐야 한다. 우리가 커스텀 함수 내부까지 들여다보는 경우는, 의도치 않게 동작할 때. 즉, 버그가 있을 때다. 이때 필요한 건 쉽게 읽히는 가독성이 아닌, 동작 원리를 세세하게 이해할 수 있는 주석과 구조다. 알고리즘의 원리를 해설하는 것처럼 말이다. 그렇기에 생명 주기 함수처럼 자연스러운 문장 구조보단, 조금 더 코드스럽더라도 원리 파악이 쉽게 주석, 구조적 간결함을 이용해야 한다.

 


 

4. 함수명은 사용되는 맥락에서 자연스러워야 한다.

클래스 단위의 분할, 함수 단위의 압축이 완료되었다면, 그다음은 문장 단위의 개선이 이루어져야 한다.

이는 보통 GPT의 도움을 받아 짓곤 하는데, 이와 별개로 적용하면 괜찮은 팁이 있다.

 

함수명을 지을 때, 호출되는 장소와 매개변수까지 고려해서 이름을 지으면 좋다. 말로만 들으면 어려운데, 예시를 살펴보자.

public class HomingMissile
{
    public void Update()
    {
    	// 유도 미사일은 생성된 순간부터 항상 날아간다.
        Move(player);
    }
    
    // 타겟을 향해 날아간다.
    private void Move(Transform target) { }
}

 

위처럼 타겟에게 날아가는 유도 미사일 클래스가 있다고 하자.

이 클래스는 활성화된 순간부터 비활성화될 때까지 날아가며, null 체크 등은 생략했다.

 

가장 먼저 떠오르는 함수명은 Move지만, Move Player라고 쓰게 된다면 이는 플레이어를 움직인다는 뜻이 된다.

 

public class HomingMissile
{
    public void Update()
    {
    	// 유도 미사일은 생성된 순간부터 항상 날아간다.
        MoveTo(player);
    }
    
    // 타겟을 향해 날아간다.
    private void MoveTo(Transform target) { }
}

 

이때 위와 같이 조사를 적절히 붙여주면, Move To Player라는 자연스러운 문장이 구사된다. 사소한 차이지만, 이런 문장들이 수십, 수백 개가 모인 코드에선 가독성의 숨은 공신이 된다.

 

우리는 앞서, 커스텀 함수는 내부를 보지 않아야 효과적이라고 했다. 그렇기에, 자연스레 함수명의 역할은 동작을 해설하는 게 아닌, 사용되는 곳에서 의미를 전달하는 것이며, 실제 동작보단 맥락에서 어떻게 쓰이냐가 더 중요한 것이다.

 

위 함수는 void 함수로, 동작에 초점이 맞춰져 있다. 하지만 값을 반환하는 함수의 경우, 함수명이 곧 변수로 변한다는 점을 상기할 필요가 있다. 예를 들면 다음과 같은 경우다.

 

public void OnEnable()
{
    if(CheckBulletIsntValid())
    {
    	gameObject.SetActive(false);
    	return;
    }
    
    target = FindClosestTarget();
}

// 총알이 파괴되어야 하는 상황인지 검사한다.
private bool CheckBulletIsntValid() { }
private Transform FindClosestTarget() { }

 

미사일 예제에 이어서, 위와 같은 상황이라고 해보자. 총알이 나오자마자 파괴되는 상황은 좀 억지스럽지만. 다양한 원인이 있다고 치자. (자동 발사 로직인데 탄창이 0인데 실수로 발사됐다든지...)

 

OnEnable의 내부를 읽어보면, 이해가 어렵진 않다. 그러나 가독성이 높다고 느껴지진 않는다. 여기엔 크게 두 가지 문제가 있다.

 

  1. 총알이 유효한지 검사하는 상황
  2. target에 '가장 가까운 타겟'을 넣는 상황

 

먼저, 총알이 유효한지 검사하는 상황이다. bool 변수를 반환하는 함수는, 일반적으로 Is, Can, Have 등으로 시작하는 규칙이 있다. 그 이유가 여기서 나온다. 우리가 if 다음에 해당 조건 함수를 읽을 때, 해당 단어들로 시작해야 자연스레 읽힌다. 만약 Check 같은 동사를 붙인다면 끝에 == true까지 붙여야 잘 읽힐 텐데, 그건 너무 길고 복잡해지니 규칙을 따르는 게 자연스럽다.

 

당장 한국어로 바꿔 읽어보아도, "만약, 총알이 유효하지 않은지 검사했을 때 참이라면"이라고 읽는 것과, "만약 총알이 유효하지 않다면"이라고 읽는 것. 후자가 훨씬 매끄럽고 편하다.

 


 

다음은 FindClosestTarget이 어색한 이유다. 이 함수는 가장 가까운 타겟을 찾는 함수로, 함수 자체로만 놓고 보면 아무 문제 없어 보인다. 어떤 역할인지 명확하고, 그리 길지도 않다.

 

사실 이 부분은 애매한 점이 많다. 다만 오직 가독성만 놓고 봤을 때, 해당 부분은 자연스레 읽히지 않는다.

이 함수는 Transform을 반환하며, 즉시 변수에 대입된다. 그렇기에 실사용 시에는 위처럼 target = FindClosestTarget이 되며, 이는 "Target is Find Closest Target", 혹은 "Target Equals Find Closest Target"처럼 읽을 수 있다. 매우 어색한 문장이다.

 

그럼 자연스럽게 고치려면 어떻게 해야 할까? 이런 함수들의 경우는, 함수가 하는 동작이 아닌, 동작을 통해 나온 결과물에 집중해 함수명을 작성해야 한다. 즉, 단순히 ClosestTarget이라고만 썼을 때 가장 자연스럽다. (target is closest target)

 

그런데, 실제로 이렇게 쓰진 않는다. 이유는 함수명을 지을 때 일종의 관습으로 굳어진, 함수명은 동사로 시작해야 한다는 부분 때문이다. 함수는 무언가의 동작이기에, 동작이라고 생각할 수 있게 이름을 동사로 짓는다. 하지만, 내 생각에 위처럼 무언가를 반환하는 경우, 어떻게 얻었는지는 관심 없고, 무엇을 얻었는지에 중점을 맞춰야 자연스럽다. 더 나아가면, 그것이 함수인지 변수인지조차 관심 없다.

(당장 위 예시도, 가장 가까운 타겟이라는 정보가 중요하지, 어떤 알고리즘을 통해 얻은 결과인진 불필요하지 않은가?)

 

그럼에도, 나도 함수명을 그렇게 짓진 않는다. 이유는 간단하다. 너무 오래 써와서, 이미 굳어졌기 때문이다. 단순히 전통을 따르는 걸 넘어, 개발자들에겐 이런 명명법이 어색하지 않다. 우린 FindClosestTarget으로 쓰더라도, 이 부분을 먼저 읽고 반환된 것을 target에 대입한다고, 자연스레 이해한다. 그렇기에 코드 자체의 가독성은 떨어질지언정, 개발자들에겐 크게 거슬리는 부분이 아닌 것이다. 그렇기에 위의 bool을 반환하는 부분과 달리, 이 부분은 실제 작업에 반영하진 않는다.

 

두 방법을 모두 적용하면, 아래와 같이 변한다.

 

public void OnEnable()
{
    if(IsBulletInvalid())
    {
    	gameObject.SetActive(false);
    	return;
    }
    
    target = FindClosestTarget();
    // target = ClosestTarget();
}

private bool IsBulletInvalid() { }
private Transform FindClosestTarget() { }

5. 변수명은 명사의 나열이 아니다.

변수명 역시 크게 다르지 않다. 오히려 변수는 더 간단하게, 전부 명사형으로 통일하면 된다. 어떻게 더 간결히 줄일지, 의미를 명확히 할지만 고민하면 된다. 단, 여기서도 주의할 점이 있다.

 

우린 흔히 변수명을 지을 때, 해당 변수의 특징을 설명하려 앞뒤로 단어를 덧붙일 때가 많다. 하지만 이건, 가독성을 말 그대로 밑바닥으로 끌어내리는 행위다. 예를 들어, 다음과 같은 변수가 있다고 하자.

 

public Vector3 UpLadderRaycastOrigin

 

사다리를 타고 올라갈 때, Raycast를 발사하는 Origin(시작점)을 담고 있는 변수다. 변수가 긴 것도 있으나, 그걸 고려하더라도 의미 파악이 어렵다. 이유는 단순하다. 변수가 가진 모든 특징을, 오직 명사로만 표현했기 때문이다.

 

변수 역시, 코드로 사용되면 문장의 일부가 된다. 그렇기에, 차라리 적절한 부사를 붙여 표현하는 게 이해에 도움이 되곤 한다. 위 변수는 다음과 같이 바꿀 수 있다.

 

public Vector3 RaycastOriginWhileClimbing

 

"사다리를 타고 올라간다"는 부분을 UpLadder로 표현하는 대신, WhileClimbing이라는 부사구로 표현해 준다면, 변수명을 읽는데도 막힘없이 뜻을 이해할 수 있다.

 

만약 변수명이 너무 길어 고민이라면, 정보를 너무 많이 담고 있다는 뜻이므로, 해당 변수가 정말 필요한 변수인가?부터 되짚어나가는 게 좋다.

 


 

마무리

이상으로, 코드에서 가독성을 높이기 위한 목표와 방향성, 그리고 구체적인 방법 5가지를 알아보았다.

 

위 방법들은 어디까지나 내가 코딩을 하며 느낀 점을 바탕으로 작성하였으며, 정답은 아니다. 만약 다르게 생각하는 부분이 있다면, 본인의 생각대로 코드를 적어 가면 될 것이다. 그저 내가 요즘 코딩할 때 어떤 것을 중점으로 두고 있으며, 어떤 방법으로 작성 중인지 정리할 겸 적은 글에 불과하다.

 

하지만 만약 다르게 생각하는 부분이 있다면, 댓글로 남겨준다면 큰 도움이 될 것 같다. 서로 생각을 나누고, 더 좋은 코드를 위해 토론할 수 있다면 그보다 기쁜 일이 있겠는가. 우리 모두의 성장을 위해, 이 글이 미약하게나마 도움이 되었으면 좋겠다.

'개발론' 카테고리의 다른 글

공부를 한다는 것은  (1) 2025.10.27