카테고리 없음

클래스는 언제 나눠야 할까? - 단일 책임 원칙(SRP) 을 코드에서 발견하는 사고 과정

경딩 2025. 6. 4. 22:52

이 글을 읽으면 얻어갈 수 있는 것

  • 클래스가 과체중이 됐다는 신호를 코드에서 감지하는 방법
  • SRP 를 추상적인 원칙이 아닌 실제 판단 기준으로 적용하는 방법
  • 인스턴스 변수 <-> 메서드 관계로 클래스 경계를 찾는 방법
  • 클래스 분리를 감이 아니라 데이터 기반으로 결정하는 사고 방식

이 글은 SRP를 지켜라 같은 교과서적인 설명이 아니라 코드를 보면서 실제로 어떻게 클래스를 나누는지, 그 사고를 정리한 글이다.


SRP를 오해하기 쉬운 이유

많은 개발자가 SRP를 이렇게 이해한다.

“클래스는 하나의 책임만 가져야 한다.”

하지만 Robert Martin이 정의한 SRP는 조금 다르다.

A class should have only one reason to change

 

핵심은 책임의 개수가 아니라 변경의 이유다. 클래스가 여러 이유로 수정된다면 그 클래스는 이미 여러 책임이 섞여 있다.

 

SRP 를 알아도 실제로 적용하기 어려운, 변경의 이유가 몇 개인지 코드만 봐서는 잘 모르기 때문이다. 코드가 신호를 보내고 있는데 우리가 그 신호를 읽는 법을 모르는 것이다.


 

어 이 클래스 왜 이렇게 커졌지?

개발을 하다 보면 이런 상황이 생긴다.

  • UserService가 어느 순간 500줄이 넘어있다
  • GameManager가 게임 루프도 돌리고, 입력도 받고, 화면도 그리고, 이동도 처리한다
  • 기능 하나 고치려는데 어디를 건드려야 하는지 감이 안 온다

이건 실력 문제가 아니다. 처음에 단순했던 클래스가 요구 사항이 늘면서 자연스럽게 커진 것이다.

 

문제는 그 다음이다. 어디서부터 나눠야 하지?

 

SRP가 어려운 이유는 원칙이 틀려서가 아니라 판단 기준이 모호하기 때문이다.

그래서 실제로 클래스를 나눌 때 사용할 수 있는 하나의 사고 과정을 정리해보았다.


전체 흐름 미리 보기

단계 목적 핵심 질문
1단계 책임 감지 "그리고"가 몇 번 나오는가
2단계 책임 파악 어떤 역할 그룹이 있는가
3단계 경계 탐색 어떤 데이터가 함께 사용되는가
4단계 객체 분리 이 데이터의 주인은 누구인가

 


1단계 - 클래스 책임을 한 문장으로 써본다.

코드를 바로 분석하지 않는다. 먼저 이 질문은 한다.

이 클래스는 무엇을 하는 클래스 인가?

 

아래가 대상 코드이다.

 

 

 

package org.eternity.adventure;

import java.util.Scanner;

public class Game {
    private Size size;
    private Room[] rooms;
    private Position position;
    private boolean running;

    public Game() {
        this.position = Position.of(0, 2);
        this.size = Size.with(2, 3);
        this.rooms = arrangeRooms(
                new Room(Position.of(0, 0), "샘", "아름다운 샘물이 흐르는 곳입니다. 이곳에서 휴식을 취할 수 있습니다."),
                new Room(Position.of(0, 1), "다리", "큰 강 위에 돌로 만든 커다란 다리가 있습니다."),
                new Room(Position.of(1, 1), "성", "용왕이 살고 있는 성에 도착했습니다."),
                new Room(Position.of(0, 2), "언덕", "저 멀리 성이 보이고 언덕 아래로 좁은 길이 나 있습니다."),
                new Room(Position.of(1, 2), "동굴", "어둠에 잠긴 동굴 안에 작은 화톳불이 피어 있습니다."));
    }

    private Room[] arrangeRooms(Room ... rooms) {
        Room[] result = new Room[size.area()];
        for(var room : rooms) {
            result[size.indexOf(room.position())] = room;
        }
        return result;
    }

    public void run() {
        welcome();
        play();
        farewell();
    }

    private void welcome() {
        showGreetings();
        showRoom();
        showHelp();
    }

    private void showGreetings() {
        System.out.println("환영합니다!");
    }

    private void showRoom() {
        System.out.println("당신은 [" + roomAt(position).name() + "]에 있습니다.");
        System.out.println(roomAt(position).description());
    }

    private void showHelp() {
        System.out.println("다음 명령어를 사용할 수 있습니다.");
        System.out.println("go {north|east|south|west} - 이동, look - 보기, help - 도움말, quit - 게임 종료");
    }

    private void farewell() {
        System.out.println("\n게임을 종료합니다.");
    }

    private void showBlocked() {
        System.out.println("이동할 수 없습니다.");
    }

    private void play() {
        Scanner scanner = new Scanner(System.in);

        start();
        while (isRunning()) {
            String input = inputCommand(scanner);
            parseCommand(input);
        }
    }

    private boolean isRunning() {
        return running == true;
    }

    private String inputCommand(Scanner scanner) {
        showPrompt();
        return input(scanner);
    }

    private void start() {
        running = true;
    }

    private void stop() {
        this.running = false;
    }

    private void parseCommand(String input) {
        String[] commands = input.toLowerCase().trim().split("\\s+");

        switch (commands[0]) {
            case "go" -> {
                switch (commands[1]) {
                    case "north" -> tryMove(Direction.NORTH);
                    case "south" -> tryMove(Direction.SOUTH);
                    case "east" -> tryMove(Direction.EAST);
                    case "west" -> tryMove(Direction.WEST);
                    default -> showUnknownCommand();
                }
            }
            case "look" -> showRoom();
            case "help" -> showHelp();
            case "quit" -> stop();
            default -> showUnknownCommand();
        }
    }

    private void showUnknownCommand() {
        System.out.println("이해할 수 없는 명령어입니다.");
    }

    private String input(Scanner scanner) {
        return scanner.nextLine();
    }

    private void showPrompt() {
        System.out.print("> ");
    }

    private void tryMove(Direction direction) {
        if (isBlocked(position.shift(direction))) {
            showBlocked();
        } else {
            position = position.shift(direction);
            showRoom();
        }
    }

    private boolean isBlocked(Position position) {
        return isExcluded(position) || roomAt(position) == null;
    }

    private boolean isExcluded(Position position) {
        return !size.contains(position);
    }

    private Room roomAt(Position position) {
        return rooms[size.indexOf(position)];
    }
}

 

 

이 클래스의 책임을 한 문장으로 쓰면 이렇게 된다.

게임 루프를 실행하고, 사용자 입력을 받고, 입력을 파싱하고, 이동을 처리하고, 결과를 출력한다

 

적은 책임이 정확하지 않더라도 상관없다. 중요한 건 하나다.

 

그리고 가 몇번 등장했는가

 

클래스 책임을 한 문장으로 쓸 때 "그리고" 가 여러 번 등장한다면 그 클래스는 이미 여러 책임을 가지고 있다.

위 문장에는 이미 5개의 책임이 들어있다,

게임 루프를 실행하고
입력을 받고
명령을 파싱하고
이동을 처리하고
결과를 출력한다
 

이 단계의 목적은 정확히 나누는 것이 아니라 과체중 신호를 발견하는 것이다.


2단계 - 메서드를 목적별로 그룹화한다

다음으로 이름이나 목적이 비슷한 메서드들을 그룹으로 묶어 나열한다. 처음부터 너무 정확할 필요는 없다.

초안을 잡는다는 생각으로 클래스를 어떻게 분리할지에 대한 대략적인 방향성을 잡는게 목적이다.

 

다음은 클래스의 메서드를 역할 기준으로 묶어본다.

그룹 메서드
출력 showGreetings, showRoom, showHelp
입력 inputCommand
게임 흐름 run, play, start, stop
명령 처리 parseCommand
이동 tryMove
지도 관리 roomAt, arrangeRooms

 

 

이 단계에서는 완변한 분류가 필요없다. 중요한 것은 이 클래스 안에 어떤 책임들이 섞여 있는지 파악하는 것이다.이건 어디까지나 초안이다.


3단계 - 실제 클래스 경계는 데이터가 알려준다

여기서부터가 핵심이다.

2단계에서 대략적으로 감을 잡았다면, 이제 인스턴스 변수와 메서드 사이의 관계를 분석한다.

코드가 너무 복잡해서 코드를 읽는 것만으로 따라가기 어렵다면 기능 스케치를 그려보는게 도움이 된다.

 

먼저 게임클래스에 포함된 인스턴스 변수를 기능 스케치에 추가한다.

 

 

 

게임클래스의 인스턴스 변수는 다음과 같다.

  • running, size, rooms, position 

이제 각 메서드가 어떤 인스턴스 변수를 사용하는지 연결해보면, 메서드들이 자연스럽게 클러스터를 이룬다는 사실이 드러난다.

클래스를 나누는 기준은 메서드 이름이 아니라 데이터다.

 

 

기능 스케치를 그리면 메서드들이 인스턴스 변수를 어떻게 참조하는지가 비교적 선명하게 드러난다.

리펙토링이 필요한 클래스를 보면 특정 인스턴스 그룹에 접근하는 메서드들이 그룹을 지어서

나눠진다는 사실을 알 수 있다.

 

 

 

 

 

게임 클래스에서는 3가지 그룹으로 나눌 수 있다.

  • running을 참조하는 그룹 → 게임 상태 관리
  • position을 참조하는 그룹 → 플레이어 이동
  • size + rooms를 함께 참조하는 그룹 → 지도 구조

여기서 중요한 패턴이 보인다.

size + rooms는 항상 함께 사용된다.

이것은 강력한 신호다. 함께 움직이는 데이터는 같은 객체에 속해야 한다.

이것이 응집도다.

 

이것이 응집도이다. 

응집도가 높은 클래스 : 메서드들이 같은 데이터를 중심으로 동작한다.

응집도가 낮은 클래스 : 메서드들이 서로 다른 데이터를 사용한다.

 

응집도가 낮은 클래스 내부에는 자연스럽게 여러개의 데이터-행동 클러스터가 생긴다. 이 클래스터가 바로 새로운 클래스의 후보다.

 


4단계- 데이터를 기준으로 객체를 분리한다

앞에서 본 것처럼 size 와 rooms 는 사실 지도 구조를 표현한다. 그런데 지금은 Game이 이걸 관리하고 있다.

 

객체 지향 설계에는 중요한 원칙이 있다.

데이터를 가진 객체가 그 데이터를 관리해야 한다.

 

그래서 WorldMap 객체를 만든다.

 

분리 전

private Room roomAt(Position position) {
    return rooms[size.indexOf(position)];
}

private boolean isExcluded(Position position) {
    return !size.contains(position);
}

 

분리 후

public class WorldMap {

    private Size size;
    private Room[] rooms;

    public Room roomAt(Position position) {
        ...
    }

    public boolean contains(Position position) {
        ...
    }
}

 

이렇게 하면 Game 은 더 이상 지도 구조를 직접 관리하지 않는다.

 

분리 이후의 구조

Game
 ├── GameState   (running 관리)
 ├── Player      (position, 이동 처리)
 └── WorldMap    (size, rooms, 지도 구조)

 

각 객체는 자신의 데이터와 책임만 관리한다. 그 결과 변경의 이유가 객체 단위로 분리된다.

  • 지도 구조 변경 → WorldMap
  • 게임 상태 변경 → GameState
  • 이동 로직 변경 → Player

이것이 SRP가 만들어내는 구조다.


실무에서 마주치는 또 다른 신호- private 메서드를 테스트하고 싶어질 때

실무에서 자주 나타나는 신호가 하나있다

private 메서드를 테스트 하고 싶어지는 순간이다.

보통 이런 상황이다.

  • 메서드가 길어졌다
  • 로직이 복잡해졌다
  • 테스트가 필요해 보인다
  • 그래서 private 메서드를 테스트하고 싶어진다.

하지만 Java에서는 private 메서드를 직접 테스트할 수 없다.여기서 중요한 질문이 생긴다.

  • 왜 이 메서드를 테스트 하고 싶은가?

답은 단순하다. 이미 그 로직이 하나의 독립적인 책임으로 성장했기 떄문이다.

이럴 때 해야할 일은 테스트 트릭을 찾는 것이 아니라 새로운 클래스를 만드는 것이다. private 메서드를 테스트하고 싶다는 욕구 자체가 클래스 분리의 신호다.

 


마무리 - 객체 = 데이터 + 책임

클래스를 나누는 기준은 메서드 개수도 아니고 ,코드 줄 수도 아니다. 가장 중요한 질문은 이것이다.

이 데이터의 책임은 어떤 객체가 가져야 하는가?

객체지향에서 객체는 데이터 + 그 데이터에 대한 책임이다. 이 구조가 유지될 때

코드는 이해하기 쉽고, 수정에 강하며, 확장하기 쉬운 구조로 자란다.

 

SRP 는 처음부터 완벽하게 지켜지는 원칙이 아니다. 대부분의 클래스는 작게 시작해서 요구사항과 함께 커진다. 좋은 설계는 처음부터 완벽한 구조를 만드는 것이 아니라, 코드가 보내는 신호를 읽는 능력에서 시작된다.

 

예를 들면 이런 신호들이다.

 
  • 클래스 설명에 “그리고”가 많아진다
  • 메서드들이 서로 다른 데이터를 사용한다
  • private 메서드를 테스트하고 싶어진다

이 신호들을 발견하는 순간 클래스는 이미 다음 단계의 설계를 요구하고 있다.

더보기