JAVA

5-1 단일 책임 원칙

경딩 2025. 5. 29. 22:07

 

https://newfangled.tistory.com/164

 

값 객체(Value Object)를 사용해야 할 때 – Game 클래스 리팩터링 예시

작은 클래스, 단순한 구조, 더 명확한 의도 표현들어가며작고 단순한 클래스를 만들기 위해 값 객체(Value Object) 를 활용할 수 있다.이번 글에서는 "게임 매출 관리 애플리케이션" 을 예시로,값 객

newfangled.tistory.com

 

클래스를 분리하는 두 가지 방법이 있다.

  • 값 객체 (지난 포스팅 참고)
  • 단일 책임 원칙

단일 책임 원칙을 사용해 클래스를 분리해보자.

 

예제 - 반복 일정 관리

반복적인 일정을 관리하는 애플리케이션

한달에 한번씩 반복되는 일정을 등록할 수 있다.

 

 

예를 들어, 월간 회의를 매달 두 번째 주 월요일 오후 2시부터 1시간까지 반복일정으로 관리할 수 있다.

반복 일정은 스케줄 클래스로 분리한다. 스케줄클래스는 반복 일정을 관리하기 위해 필요한 인스턴스 변수를 포함한다.

 

title 변수 =  월간회의

ordinal = 2 (두번째주 할당)

dayOfWeek = 월요일

from = 오후 2시

duration = 1시간

 

애플리케이션은 특정한 날짜에 일정이 열리는지 알아내는 기능을 제공해야 한다.

ex) 2025년 1월 15일에 월간회의가 열리나요?

 

해당 기능을 구현하기 위해 일정을 확인하는 includes 메소드를 추가해보자.

includes 메소드는 확인하고 싶은 날짜를 인자로 받고, 결과값으로 일정이 열리는지 여부를 반환한다.

 

메서드 구현은 간단하다.

인자로 전달된 날짜의 요일과 주차가 스케쥴에 저장된 요일과 주차와 같으면 true, 다르면 false 반환

 


스케줄 정보를 JSON 포맷으로 변환하는 기능도 제공하여야한다.

애플리케이션은 스케줄 클래스의 인스턴스 변수를 이용하여 반복 일정을 JSON FORMAT 으로 마샬링 해야한다.

예제에서는 json 으로 변환하기 위해서 잭슨 라이브러리를 사용중이다.

toJson() 메서드는  잭슨 라이브러리의 ObjectMapper 를 사용해서 일정정보를 JSON 문자열로 변환한다.


이제 반복일정을 관리하는 스케줄 클래스를 완성하였다.

package single.responsibility.v1.problem;

import com.fasterxml.jackson.annotation.JsonAutoDetect;
import com.fasterxml.jackson.annotation.JsonFormat;
import com.fasterxml.jackson.annotation.PropertyAccessor;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;

import java.time.DayOfWeek;
import java.time.Duration;
import java.time.LocalDate;
import java.time.LocalTime;

/**
 * 제목, 주차, 요일, 시작시간, 소요시간
 * 월간 회의를 등록할 수 있는 클래스
 */
public class Schedule {
    private static final int DAYS_IN_WEEK = 7;

    private String title;
    @JsonFormat(pattern = "HH:mm") private LocalTime from;
    @JsonFormat(pattern = "MINUTES")private Duration duration;


    private Integer ordinal;
    private DayOfWeek dayofWeek;

    public Schedule(String title, LocalTime from,
                    Duration duration, Integer ordinal, DayOfWeek dayofWeek) {
        this.title = title;
        this.from = from;
        this.duration = duration;
        this.ordinal = ordinal;
        this.dayofWeek = dayofWeek;
    }

    // 일정이 있는지 확인하는 메소드 - 확인하고 싶은 값  인자로 받기
    public boolean includes(LocalDate day) {
        if(!day.getDayOfWeek().equals(dayofWeek)) return false;
        // 주차 계산
        return (day.getDayOfMonth() / DAYS_IN_WEEK) + 1 == ordinal;
    }

    public String toJson() throws JsonProcessingException {
        ObjectMapper mapper = new ObjectMapper();
        mapper.registerModule(new JavaTimeModule());
        mapper.setVisibility(PropertyAccessor.ALL, JsonAutoDetect.Visibility.NONE);
        mapper.setVisibility(PropertyAccessor.FIELD, JsonAutoDetect.Visibility.ANY);
        return mapper.writeValueAsString(this);
    }
}

클래스 완성 후 클래스 크기가 너무 커진건 아닌지 평가해보는게 좋다.

그렇다면 클래스의 크기가 적당한지 어떻게 판단할 수 있을까?

 

이럴때 참고할 수 있는 원칙이 단일 책임 원칙이다.

단일 책임 원칙 - 클래스는 단 한가지의 변경 이유만을 가져야한다.

단일 책임 원칙에 따르면 클래스가 여러가지 이유로 변경되면 크기가 크다고 말하고,

클래스가 단 하나의 이유로 변경되면, 크기가 작다고 말한다.

 

여기서 말하는 책임은 책임 주도 설계에서 말하는 책임과는 다르다.

  • 책임 주도 설계에서 책임은 객체가 다른 객체와 협력하기 위해 외부에 제공해야 하는 서비스의 개념 
  • 단일 책임 원칙에서 책임은 클래스가 변경되는 이유를 말한다.
  • 따라서 단일 책임 원칙에서 책임이 책임 주도 설계에서 책임보다 큰 개념이다.

단일 책임 원칙은 응집도와 관련이 높다.

응집도모듈안에 포함된 요소들이 함께 변경되는 정도이다. 

 

모듈안에 있는 요소들이 같은 이유로 함께 변경 될 때, 응집도가 높다고 말한다.

반면에 모듈안에 있는 요소들이 서로 다른 이유로 변경될때, 응집도가 낮다고 말한다.

 

이해하기 쉽고 안전하게 설계할 수 있는  클래스는 일반적으로 응집도가 높다.

단일 책임 원칙은 한마디로 응집도가 높은 클래스를 만드는 원칙이다.

단일 책임 원칙을 만족하도록 클래스를 만들면 응집도가 높아진다. 단일 책임 원칙을 위반하면 클래스의 응집도가 낮아진다.

 

따라서 클래스의 크기를 적절하게 유지하기 위해서는 변경의 이유에 따라 클래스를 분리하는게 중요하다.

 

 

응집도와 단일 책임 모두 변경에 대해 이야기 한다.

아마도 변경에 따라 클래스를 분리하지 않으면 어떤 문제가 발생할 것이기 때문이다.

따라서 응집도와 단일 책임 원칙을 이해하려면 원칙을 어겼을 때 발생 할 수 있는 문제를 살펴보자.

 

 

3가지 서로 다른 이유로 변경되는 코드를 포함하는 클래스가 있다고 가정하자.

클래스안에 원과 삼각현, 사각형은 각각 서로 다른 이유로 변경되는 코드 집합을 나타낸다.

서로 다른 이유로 변경된다는 말은 서로 다른 개발자가 서로 다른 시점에 서로 다른 코드를 변경한다는 것을 의미한다.

 

이 경우 한 개발자는 원으로 표시된 부분만 수정하고 다른 개발자는 삼각형, 또 다른 개발자는  네모 부분만 수정한다.

문제는 이 코드들이 한 클래스 안에 뭉쳐있기 때문에 서로 영향을 주고 받기가 쉽다.

 

개발자는 원만 수정했지만 그 여파로 삼각형과 사각형의 일부 코드도 함께 수정될 수 있다.

이런 예상하지 못한 부수효과 때문에 많은 버그가 발생하게 된다.

개발자들이 동시에 이 클래스를 수정할 경우 문제가 더 커진다.

자기가 변경하는 부분과 상관없는 코드에 영향을 줬다면 수정된 코드들 사이에 충돌이 발생할 수 밖에 없다.

그리고 이 모든 부수효과는 예상치 못한 버그를 불러올 수 있다.

 

이 문제를 해결하면 개발자가 자신이 담당하는 코드만 수정할 수 있게 만들면 된다.

이렇게 할 수 있는 가장 쉬운 방법은 변경의 단위로 클래스를 나누는 것이다.

변경에 이유에 따라 클래스를 분리하는 것이다.

 

원과 사각형과 삼각형을 서로 다른 클래스로 분리하면 원을 수정하는 개발자가 실수로라도 삼각형이나 사각형을 수정할 확률을 낮출 수 있다. 코드를 수정하기도 쉬워지고 수정하기 위해  읽어야 할 코드의 양도 적어진다.

이렇게 변경의 이유에 따라 클래스를 분리하면 수정하기 쉽고 버그가 적은 클래스를 만들 수 있다.


단일 책임 원칙은 결합도와 관련이 높다.

결합도는 다른 모듈에 의해 함께 변경되는 정도를 의미한다.

 

 

 

외부의 다른 모듈이 변경될 때 함께 변경되는 빈도가 높으면 결합도가 높다.

외부의 다른 모듈이 변경될 때 함께 변경되는 빈도가 낮으면 결합도가 낮다고 한다.

 

 

지금 보는 클래스를 세가지 서로 다른 이유로 변경된다.  서로 다른 이유로 변경되는 코드들은 서로 다른 작업을 수행하기 때문에 각각 다른 클래스에 의존하게 된다.

여기변경의 방향은 의존성의 방향과 반대로 흐른다.

 

즉 원과 삼각형과 사각형은 서로 다른 클래스가 변경될 때 영향을 받게 된다.

 

예를 들어 원이 의존하는 외부 클래스가 변경되면  이 클래스에 의존하는 원이 영향을 받아 수정 될 수 있다.

그리고 그 여파로 삼각형과 사각형까지 영향을 받을 확률이 높다.

 

외부 클래스의 변경으로 여파가 변경과 상관이 없는 코드에도 영향을 미칠 수 있다.

외부에 의존하는 클래스가 많아질 수록 코드가 자주 변경될 수 밖에 없다.

원이 의존하는 클래스뿐만 아니라 삼각형과 사각형이 의존하는 클래스가 변경될 때도 원이 함께 변경될 수 있다.

 

따라서 서로 다른 이유로 변경되는 코드들이 한데 묻혀 있으면 결합도가 높아지게 된다.

 

이 문제를 해결하는 방법도 응집도의 경우와 동일하다.

서로 다른 이유로 변경되는 코드를 서로 다른 클래스로 분리하면 된다.

변경에 따라 코드를 분리하면 의존하는 외부 클래스가 서로 분리되기 때문에 외부 클래스에 의해 변경되는 빈도를 줄일 수 있다.

 

 

 

단일 책임 원칙에 따라 클래스가 하나의 변경 이유만 같게 만들면 결합도를 낮출 수 있다.


 

이제 단일 책임 원칙을 기준으로 Schedule 클래스의 크기가 적절한지 판단해보자.

 

includes 메소드는 내부에서 특정한 날짜에 일정이 발생하는지를 판단한다.

따라서 일정을 확인하는 방식이 변경될 때 수정된다.

 

 

toJSON 메소드는 일정을 JSON 포맷으로 변경한다.

따라서 일정을 출력하는 포맷이 변경될 때 수정된다.

 

서로 상관이 없는 이유로 변경되는 코드가 함께 뭉쳐있기 때문에 스케줄 클래스의 응집도는 낮다.

 

 

toJSON 메서드는 Jackson 라이브러리의 ObjectMapper 를 사용하기 때문에 스케줄 클래스는 Jackson 라이브러리에 의존한다.

 

 

만약 JSON 포맷을 변경하고 싶다면 Jackson 라이브러리의 JSON 포캣 애노테이션을 인스턴스 변수에 추가해야 한다.

 

 

이 때문에 같은 클래스에 포함된 include 메소드도 영향을 받을 수 있고, include 메소드에  의존하는 클라이언트에게도 영향이 전파될 수. 있다. 따라서 스케줄 클래스는 결합도가 높다.

 

결론적으로 현재 Schedule 클래스는 응집도가 낮고 결합도가 높다.

단일 책임 원칙을 위반하였다.

스케줄 클래스의 문제를 해결하는 방법은 단일 책임 원칙을 따르도록 코드를 수정하는 것이다.

변경이 이유가 다른 코드를 서로 다른 클래스로 분리하면 문제를 해결 할 수 있다.

 

스케줄 클래스에서 서로 다른 이유로 변경되는 부분은  includes 와 ToJson 메서드이다.

단일 책임 원칙에 따라 두 메소드를 서로 다른 클래스로 분리해보지. 

 

include 메소드는 Schedule 클래스에 그대로 두고,  toJSON 메소드를 새로운 클래스인 Schedule Json 으로 이동시킨다.

 

단일 책임 위배 Schedule 클래스

package   single.responsibility.problem;

import org.gradle.internal.impldep.com.fasterxml.jackson.annotation.JsonAutoDetect;
import org.gradle.internal.impldep.com.fasterxml.jackson.annotation.JsonFormat;
import org.gradle.internal.impldep.com.fasterxml.jackson.annotation.PropertyAccessor;
import org.gradle.internal.impldep.com.fasterxml.jackson.core.JsonProcessingException;
import org.gradle.internal.impldep.com.fasterxml.jackson.databind.ObjectMapper;
import org.gradle.internal.impldep.com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;

import java.time.DayOfWeek;
import java.time.Duration;
import java.time.LocalDate;
import java.time.LocalTime;

public class Schedule {
    private static final int DAYS_IN_WEEK = 7;

    private String title;
    @JsonFormat(pattern = "HH:mm") private LocalTime from;
    @JsonFormat(pattern = "MINUTES") private Duration duration;

    private Integer ordinal;
    private DayOfWeek dayofWeek;

    public Schedule(String title, LocalTime from, Duration duration, Integer ordinal, DayOfWeek dayofWeek) {
        this.title = title;
        this.from = from;
        this.duration = duration;
        this.ordinal = ordinal;
        this.dayofWeek = dayofWeek;
    }

    public boolean includes(LocalDate day) {
        // 특정 주차인지 검사
        if(!day.getDayOfWeek().equals(dayofWeek)) return false;

        // 특정 요일인지 검사
        return (day.getDayOfMonth() / DAYS_IN_WEEK) + 1 == ordinal;
    }

    /**
     * 현재 객체 (this) 를 JSON 문자열로 변환하는 메서드
     * */

    public String toJson() throws JsonProcessingException {
        ObjectMapper mapper = new ObjectMapper(); // Jackson 의 JSON 변환기 생성
        mapper.registerModule(new JavaTimeModule()); // LocalDate 등 Java 8 날짜 타입 지원 추가

        // 기본적으로 모든 요소의 접근을 막고
        mapper.setVisibility(PropertyAccessor.ALL, JsonAutoDetect.Visibility.NONE);
        // 필드만 접근 가능하도록 설정(getter 없이도 직렬화 가능)
        mapper.setVisibility(PropertyAccessor.FIELD, JsonAutoDetect.Visibility.ANY);

        return mapper.writeValueAsString(this); // 현재 객체를 JSON 문자열로 반환
    }


}

 

 

리펙토링 - 변경의 이유에 따라 독립적인 클래스로 분리

 

toJSON 메소드를 새로운 ScheduleJson 으로 이동시킨다.

이렇게 분리하면 Schedule 클래스는 일정을 확인하는 방식이 변경될 때만 수정되고 

ScheduleJson 클래스는 Json 포맷이 변경될 때만 수정된다.

package single.responsibility.solution;

import org.gradle.internal.impldep.com.fasterxml.jackson.annotation.JsonFormat;

import java.time.DayOfWeek;
import java.time.Duration;
import java.time.LocalDate;
import java.time.LocalTime;

public class Schedule {
    private static final int DAYS_IN_WEEK = 7;

    private String title;
    @JsonFormat(pattern = "HH:mm") private LocalTime from;
    @JsonFormat(pattern = "MINUTES") private Duration duration;

    private Integer ordinal;
    private DayOfWeek dayofWeek;

    public Schedule(String title, LocalTime from, Duration duration, Integer ordinal, DayOfWeek dayofWeek) {
        this.title = title;
        this.from = from;
        this.duration = duration;
        this.ordinal = ordinal;
        this.dayofWeek = dayofWeek;
    }

    public boolean includes(LocalDate day) {
        // 특정 주차인지 검사
        if(!day.getDayOfWeek().equals(dayofWeek)) return false;

        // 특정 요일인지 검사
        return (day.getDayOfMonth() / DAYS_IN_WEEK) + 1 == ordinal;
    }




}

 

package single.responsibility.solution;

import org.gradle.internal.impldep.com.fasterxml.jackson.annotation.JsonAutoDetect;
import org.gradle.internal.impldep.com.fasterxml.jackson.annotation.JsonFormat;
import org.gradle.internal.impldep.com.fasterxml.jackson.annotation.PropertyAccessor;
import org.gradle.internal.impldep.com.fasterxml.jackson.core.JsonProcessingException;
import org.gradle.internal.impldep.com.fasterxml.jackson.databind.ObjectMapper;
import org.gradle.internal.impldep.com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;

import java.time.Duration;
import java.time.LocalTime;

public class ScheduleJson {

    private Schedule schedule;
    private ObjectMapper mapper;

    public ScheduleJson(Schedule schedule) {
        this.schedule = schedule;
        this.mapper = initializeMapper();
    }

    private ObjectMapper initializeMapper() {
        ObjectMapper mapper = new ObjectMapper(); // Jackson 의 JSON 변환기 생성


        mapper.registerModule(new JavaTimeModule()); // LocalDate 등 Java 8 날짜 타입 지원 추가

        mapper.configOverride(Duration.class).setFormat(JsonFormat.Value.forPattern("MINUTES"));
        mapper.configOverride(LocalTime.class).setFormat(JsonFormat.Value.forPattern("HH:mm"));
        // 기본적으로 모든 요소의 접근을 막고
        mapper.setVisibility(PropertyAccessor.ALL, JsonAutoDetect.Visibility.NONE);
        // 필드만 접근 가능하도록 설정(getter 없이도 직렬화 가능)
        mapper.setVisibility(PropertyAccessor.FIELD, JsonAutoDetect.Visibility.ANY);


        return mapper;

    }

    /**
     * 현재 객체 (this) 를 JSON 문자열로 변환하는 메서드
     * */

    public String toJson() throws JsonProcessingException {
        return mapper.writeValueAsString(this); // 현재 객체를 JSON 문자열로 반환
    }
}

 

JSON 출력 포맷 변경에 영향을 받지 않는 Schedule 클래스가 완성되었다.

JSON 포맷을 변경하고 싶을 경우에는 ScheduleJSON 클래스의 ObjectMapper 설정만 수정하면 되기 떄문에 Schedule 클래스는 영향을 받지 않는다. 이제 두 클래스는 단일 책임 원칙을 만족한다.

 

단일 책임 원칙의 핵심은 클래스를 작게 만들어야 한다는 것이다. 

큰 클래스는 서로 다른 이유로 변경되기 때문에 개발자들 사이에 충돌이 발생하기 쉽고 버그가 발생할 확률이 높아진다.

다른 개발자의 코드를 망치거나 장래를 발생할지도 모른다는 두렵움 없이 코드를 좀 더 안정적으로 수정하기 위해서는 클래스를 작게 만드는 것이 중요하다.

 

 

단일 책임 원칙을 클래스가 얼마나 작아야하는지에 대한 지침을 제공한다.

이 원칙에 따르면 클래스는 하나의 이유로 변경될 정도로만 작게 만들어야 한다.

 

 

단일 책임 원칙 관점에서 클래스를 지속적으로 리팩토링 해야한다.

단일 책임 여부를 결정하는데 도움이 되는 가이드를 확인해보자.