풀스택 개발자 과정 15일차

너구·2026년 5월 27일

풀스택 성장과정

목록 보기
15/79

오늘도 어제와 같이 코드의 좋은 예시와 나쁜 예시를 비교하며 어떤식으로 코드를 설계해야 좋을지에 대해 살펴보았다.


SRP (Single Responsibility Principle)

클래스는 하나의 책임만 가져야한다라는 원칙이다.

SRP 예제

그럼 먼저 SRP를 위반한 나쁜 예시를 확인해보겠다.

나쁜 예시

// 모든 책임을 다 지는 객체
// 데이터베이스 통신, 가입 로직, 클라이언트와 통신 모두를 책임진다
class Controller {
    // username, password: 클라이언트가 가입을 요청한 아이디와 비밀번호
    void singup(String username, String password) {

        // DB를 검색해서 이미 가입된 사용자인지 확인
        System.out.printf("DB 검색결과: %s는 가입된 사용자가 아닙니다\n", username);

        // 가입을 계속합니다
        System.out.println("가입을 계속합니다");

        // 요청받은 정보를 데이터베이스에 저장
        System.out.println("다음 사용자를 DB에 저장합니다");
        System.out.printf("{username=%s, password=%s}\n", username, password);
        System.out.println("저장 완료");

        // 클라이언트에게 응답을 전송합니다
        System.out.println("가입이 완료되었습니다" + username + "님!");
    }
}

public class Main {
    public static void main(String[] args) {

        Controller controller = new Controller();

        //클라이언트가 전송한 정보
        String username = "johndoe";
        String password = "1234";

        // 서버 프로그램이 컨트롤러를 호출
        controller.singup(username, password);
    }
}

이 코드는 SRP(단일 책임 원칙)을 지키지 못한 대표적인 예시라고 볼 수 있다.
왜냐면 Controller가 너무 많은 일을 혼자 다 하고 있기 때문이다.

현재 Controller는 사용자 중복 여부를 검사하고 있다.
즉 회원 검증 책임을 가지고 있다.
또한 회원가입 진행 여부를 판단하며 비즈니스 로직 책임도 가지고 있다.
회원정보를 데이터베이스에 저장하고 사용자에게 응답 메시지를 보내는 역할도 하고 있다.
즉 하나의 클래스가 검증, 가입처리, DB 저장, 응답처리를 전부 다 담당하고 있다라는 것이다.
이게 바로 책임이 너무 많은 구조라고 볼 수 있다.

문제는 나중에 DB 저장 방식이 바뀌거나 응답 형식이 바뀌거나 가입 검증 규칙이 바뀌거나 하면 전부 Controllor를 수정해야 한다라는 점이다.

이게 바로 SRP 위반이다.

그렇다면 좋은 예시는 어떨까?

좋은 예시

// 데이터베이스와 통신하는 객체
class UserRepository {
    // 입력받은 아이디로 데이터베이스를 검색 (아이디중복 확인용)
    void findUserByUsername(String username) {
        System.out.printf("DB 검색 결과: %s는 가입된 아이디가 아닙니다\n", username);
    }

    // 입력받은 사용자 정보를 데이터베이스에 저장
    void save(String username, String password) {
        System.out.println("다음 사용자를 데이터베이스에 저장합니다");
        System.out.printf("{username=%s, password=%s}\n", username, password);
        System.out.println("저장 완료");
    }
}

// 로직 처리
class UserService{
    // DAO에게 의존
    // DAO(Data Access Objact): 데이터 접근 개체 (리포지토리)
    UserRepository userRepository;

    UserService(UserRepository userRepository) {
        this.userRepository = userRepository;
    }

    // 가입처리 로직
    void register(String username, String password) {

        // DAO에게 아이디 중복체크를 요청
        userRepository.findUserByUsername(username);

        System.out.println("가입을 계속합니다");

        // 사용자 지정을 DAO에게 요청합니다
        userRepository.save(username, password);
    }
}

// 클라이언트와 통신 담당
class UserController {
    // 서비스에 의존
    UserService userService;

    UserController(UserService userService) {
        this.userService = userService;
    }

    // username, password: 클라이언트가 가입 요청할 때 보낸 정보
    void singUp(String username, String password) {

        // 서비스에게 가입 로직을 요청
        userService.register(username, password);

        // 클라이언트에게 응답을 전송합니다
        System.out.println("가입이 완료되었습니다" + username + "님!");
    }
}

public class Main {
    public static void main(String[] args) {
        
        // 의존성 주입
        UserRepository userRepository = new UserRepository();
        UserService userService = new UserService(userRepository);
        UserController userController = new UserController(userService);

        // 서버로 날라온 가입요청 정보
        String username = "johndoe";
        String password = "1234";

        // 컨트롤러 호출
        userController.singUp(username, password);
    }
}

이번 코드는 SRP를 잘 적용한 좋은 예시라고 볼 수 있다.
이전 나쁜 예시에서는 하나의 Controller가 모든 일을 혼자 처리하고 있었지만 이번 코드에서는 역할을 각각의클래스가 나누어서 담당하고 있다.

UserRepository는 데이터베이스와 관련된 작업만 담당한다.
UserService는 비즈니스 로직을 담당하고 있다.
UserController는 사용자의 요청을 받고 응답하는 역할만 담당한다.

즉 이번 구조는 역할이 명확하게 분리되어있다라는 것이다.

이 구조의 가장 큰 장점은 수정 이유가 분리된다라는 점이다.
예를들어 DB 구조가 바뀌면 Repository를 수정하면 되고 가입 로직이 바뀌면 Service를 수정하면 된다.
즉 하나의 수정이 다른 클래스에 영향을 덜 주게 된다라는 것이다.

즉 이번 코드는 객체지향에서 중요한 SRP를 잘 적용한 코드라고 볼 수 있다.


ISP (Interface Segregation Principle)

인터페이스 분리 원칙이라고 부른다.
필요하지 않은 기능까지 강제로 구현하게 만들지 말자라는 원칙이다.

ISP 예제

나쁜 예시

// 복합기 인터페이스
interface MFD {
    // 관련없는 기능들을 한곳에 모음
    void print();
    void scan();
    void fax();
}

// 구현 클래스들
class Printer implements MFD {
    public void print() {
        System.out.println("프린트중");
    }
    public void scan() {
        System.out.println("지원하지 않는 기능");
    }
    public void fax() {
        System.out.println("지원하지 않는 기능");
    }
}

// 스캐너
class Scanner implements MFD{
    public void print() {
        System.out.println("지원하지 않는 기능");
    }
    public void scan() {
        System.out.println("스캔중");
    }
    public void fax() {
        System.out.println("지원하지 않는 기능");
    }
}

// 팩스

class Fax implements MFD {
    public void print() {
        System.out.println("지원하지 않는 기능");
    }
    public void scan() {
        System.out.println("지원하지 않는 기능");
    }
    public void fax() {
        System.out.println("팩스 보내는 중");
    }
}

// 클라이언트 (프린터를 사용하는 회사)
class Company {
    MFD printer;

    Company(MFD printer) {
        this.printer = printer;
    }

    // 회사의 업무
    void work() {
        System.out.println("문서 작성중");
        System.out.println("이제 인쇄해야지");

        // 프린터만 쓰는 회사
        printer.print();

        // 의미없는 메서드 호출 가능
        printer.scan();
        printer.fax();

        System.out.println("업무 끝!");
    }
}

public class Main {
    public static void main(String[] args) {
        Company company = new Company(new Printer());
        company.work();

        System.out.println("업무 끝!");
    }
}

이 코드는 ISP를 지키지 못한 나쁜 예시이다.
왜냐면 interface MFD가 너무 많은 기능을 한꺼번에 가지고 있기 때문이다.

현재 MFD 인터페이스에는 print, scan, fax가 모두 들어있다.
문제는 Printer 클래스는 사실 출력만 가능하면 된다라는 것이다.
그런데 인터페이스 때문에 scan과 fax까지 억지로 구현해야 한다.
그래서 "지원하지 않는 기능" 같은 의미없는 코드가 생긴 것이다.

Scanner도 마찬가지다.
스캔만 하면 되는데 Print와 Fax까지 강제로 구현하고 있다.

즉 현재 구조는 "필요하지 않은 기능까지 구현하게 만드는 구조"이다.
이게 바로 ISP 위반이다.

또 문제는 Company 클래스에서 printer.sacn(); printer.fax(); 같은 이미없는 호출도 가능해진다라는 점이다.
회사에서는 프린터만 사용하려고 했는데, 인터페이스가 너무 거대하다 보니 사용하지 않을 기능까지 접근한 상태가 되어버린 것이다.

그렇다면 좋은 예시는 어떨까?

좋은 예시

// 기능별로 인터페이스를 분리
interface Printer {
    void print();
}

interface Scanner {
    void scan();
}

interface Fax {
    void fax();
}

// 구현 클래스들
class TestPrinter implements Printer {
    public void print() {
        System.out.println("프린트중");
    }
}
class TestScanner implements Scanner {
    public void scan() {
        System.out.println("스캔중");
    }
}
class TestFax implements Fax {
    public void fax() {
        System.out.println("팩스 보내는 중");
    }
}
// 클라이언트 (프린터를 사용하는 회사)
class Company {
    Printer printer;

    Company(Printer printer) {
        this.printer = printer;
    }
    void work() {
        System.out.println("문서 작성중");
        System.out.println("이제 인쇄해야지");
        // 프린트만 가능
        printer.print();
        System.out.println("업무 끝!");
    }
}

public class Main {
    public static void main(String[] args) {
        Company company = new Company(new TestPrinter());
        company.work();
    }
}

이번 코드는 ISP를 잘 적용한 좋은 예시라고 볼 수 있다.

이전 나쁜 예시에서는 interface MFD 하나에 프린트, 스캔, 팩스 모든 기능이 전부 들어있었다.
그래서 프린터는 스캔 기능까찌 구현해야 했고, 스캐너너는 팩스 기능까지 구현해야 했다.

하지만 이번 코드에서는 인터페이스를 기능별로 분리하였다.
interface Printer
interface Scanner
interface Fax 처럼 역할을 나눈 것이다.
그래서 class TestPrinter implements Printer는 출력 기능만 구현하면 된다.
즉 print()만 구현하면 되고 불필요한 scan(), fax() 메서드를 억지로 만들 필요가 없어졌다.
TestScanner와 TestFax도 마찬가지다.
작 클래스가 자신에게 필요한 기능만 구현하게 된 것이다.

또 Company 클래스도 좋아졌다.
이전 코드에서는 print.scan(); print.fax() 같은 의미없는 호출이 가능했다.
하지만 지금은 Print printer;만 사용하기 때문에 printer.print();만 사용할 수 있다.
즉 회사는 출력 기능만 사용 가능하고 불필요한 기능엔 접근할 수 없는 상태가 된 것이다.

즉 이번 코드는 인터페이스를 작고 역할별로 분리한 좋은 객체지향 구조라고 볼 수 있다.


Builder 예제

나쁜 예시

class Person {
    private String name;
    private String email;
    private String address;
    private int age;

    // 생성자들
    // 생성자 오버로딩
    // 첫번째 생성자: 모든 정보를 입력받습니다
    Person(String name, String email, String address, int age) {
        this.name = name;
        this.email = email;
        this.address = address;
        this.age = age;
    }
    // 나이를 입력받지 않음
    Person(String name, String email, String address) {
        // this: 첫번째 생성자
        this(name, email, address, 0);
    }

    // 주소, 나이를 입력받지 않음
    Person(String name, String email) {
        // this: 첫번째 생성자
        this(name, email, "", 0);
    }
    // 비즈니스 로직 (객체의 핵심 기능)
    public void eat() {
        System.out.println("밥 먹는중");
    }
    public void work() {
        System.out.println("일하는 중");
    }
    public void sleep() {
        System.out.println("잠자는 중");
    }
    // 객체 정보를 출력하는 메서드
    @Override
    public String toString() {
        return String.format("Person{name=%s, email=%s, address=%s, age=%s", name, email, address, age);
    }
}

public class Main {
    public static void main(String[] args) {
        // Full info
        Person john = new Person("John Doe", "Johndoe@example.com", "xxx", 30);
        System.out.println(john);

        // 나이비공개
        Person jane = new Person("Jane Doe", "janedoe@example.com", "xxx");
        System.out.println(jane);

        // 나이, 주소 비공개
        Person mary = new Person("Mary Doe", "martdoe@example.com");
        System.out.println(mary);
    }
}

이 코드는 완전히 잘못된 코드는 아니지만 생성자 오버로딩이 너무 많아질 수 있다는 문제를 가진 구조라고 볼 수 있다.
현재는 필드가 name, email, address, age 4개라서 괜찮아보인다.
하지만 필드가 점점 많아지면 문제가 커지게 된다.
phone, gender, birthday, job... 이런 값들이 계속 추가된다면 생성자도 늘어나게 된다.
Person(name, email)
Person(name, email, age)
Person(name, email, address)
Person(name, email, address, age)
이런식으로 조합이 끝없이 생기게 되는 거다.
즉 생성자 폭발(Constructor Explosion) 이라는 문제가 발생한다.

또 문제는 매개변수 순서를 헷갈리기 쉽다는 점이다.
예를들면 new Person("john", "aaa", "seoul", 20)에서 aaa가 이메일인지 주소인지 한 눈에 보기가 어렵다.
그리고 선택값이 많아질수록 "", 0 같은 기본값 처리도 계속 늘어나게 된다.
즉 코드 가독성이 떨어지게 되는 것이다.

그렇다면 좋은 예시는 어떻게 작성할까?

좋은 예시

class Person {
    // 멤버 변수
    private String name;
    private String email;
    private String address;
    private int age;

    // 생성자에 접근이 금지 -> 생성자로 인스턴스 생성 불가
    private Person(Builder builder) {
        this.name = builder.name;
        this.email = builder.email;
        this.address = builder.adress;
        this.age = builder.age;
    }

    // 빌더 인스턴스를 반환하는 정적 메서드
    public static Builder builder() {
        return new Builder();
    }

    // 비즈니스 로직들 (객체의 핵심 기능)
    public void eat() {
        System.out.println("밥 먹는 중");
    }
    public void work() {
        System.out.println("일 하는 중");
    }
    public void sleep() {
        System.out.println("잠 자는 중");
    }

    // 객체 출력용 메서드
    @Override
    public String toString() {
        return String.format("Person{name=%s, email=%s, address=%s, age=%s", name, email, address, age);
    }
    
    // 빌더 클래스 -> 객체 생성 과정을 위임받음
    // 내부 클래스 (Inner Class)
    public static class Builder {

        // 빌더의 멤버 변수들, 기본값 설정
        // 따로 정보를 입력받지 않으면 기본값 적용
        private String name = "";
        private String email = "";
        private String adress = "";
        private int age = 0;

        // 빌더의 생성자
        public Builder() {}

        // 멤버 변수의 값을 설정하는 메서드들
        public Builder name(String name) {
            this.name = name;
            return this; // this는 Builder 인스턴스를 의미합니다
        }

        // 이메일 설정
        public Builder email(String email) {
            this.email = email;
            return this;
        }

        // 주소 설정
        public Builder address(String adress) {
            this.adress = adress;
            return this;
        }

        // 나이 설정
        public Builder age(int age) {
            this.age = age;
            return this;
        }

        // 빌더 인스턴스를 바깥 클래스의 생성자에게 전달하는 메서드
        public Person build() {
            return new Person(this);
        }
    }
}

public class Main {
    public static void main(String[] args) {
        // 모든 정보로 객체 생성하기
        // 가독성
        Person john = Person.builder()
        .name("John Doe") // 메서드 체이닝(chaining)
        .email("johndoe@email.com")
        .address("xxx")
        .age(30)
        .build();

        System.out.println(john);

        // 나이 없음
        Person jane = Person.builder()
        .name("Jane Doe")
        .email("janedoe@example.com")
        .address("xxx")
        .build();

        System.out.println(jane);

        // 나이, 주소 없음
        Person mary = Person.builder()
        .name("Mary Doe")
        .email("marydoe@example.com")
        .build();

        System.out.println(mary);
    }
}

이 코드는 Builder 패턴을 활용해서 객체 생성을 훨씬 유연하고 깔끔하게 만든 좋은 예시라고 볼 수 있다.
이전 나쁜 예시에서는 new Person(name, email, address, age)처럼 생성자 오버로딩을 계속 사용했다.
하지만 이번 코드는 객체 생성 과정을 Builder 클래스에 맡겼다.
즉 객체를 한 번에 생성하는 것이 아니라 .name() .email()... 처럼 필요한 값만 하나씩 설정할 수 있게 만든 것이다.
그래서 코드가 훨씬 읽기 쉬워졌다.
어떤 값이 어떤 필드에 들어가는지 한 눈에 바로 알 수 있다라는 것이다.

또 좋은 점은 필요한 값만 선택적으로 넣을 수 있다는 점이다.
예를들어 .address() .age()를 생략해도 객체 생성이 가능하다.
즉 선택값 처리가 훨씬 자연스러워졌다.

그리고 생성자도 좋아졌다.
private Person(Builder builder)처럼 외부에서 직접 생성자를 호출하지 못하게 막았다.
즉 객체 생성 방식을 Builder를 통해서만 생성하도록 강제한 것이다.
덕분에 객체 생성 규칙을 통일할 수 있게 되었다.

또 return this;를 활용한 메서드 체이닝(Method Chaining)기법도 사용했따.
그래서 .name() .email() .age()를 자연스럽게 이어서 사용할 수 있게 된 것이다.

즉 이번 코드는 복잡한 객체 생성을 Builder 패턴으로 깔끔하게 개선한 좋은 객체지향 코드라고 볼 수 있다.


마무리

오늘은 객체지향 설계와 리팩토링에 대해 깊게 배우는 시간이었다.
처음에는 단순히 코드가 실행되기만 하면 된다고 생각했는데 오늘 공부하면서 "좋은 코드"는 단순히 동작하는 코드가 아니라 유지보수와 확장성까지 고려한 코드라는 것을 알게 되었다.
SRP, ISP, Builder 패턴 같은 개념들을 직접 코드로 구현해보면서 객체지향 구조를 조금 더 이해할 수 있었다.
가장 인상깊었던 부분은 Builder 패턴을 배우면서 객체 생성도 단순히 생성자를 사용하는 것이 아니라 더 읽기 쉽고 유지보수하기 좋은 방식으로 개선할 수 있다라는 것을 알게 되었다.
오늘은 어떻게 동작하느냐 뿐만 아니라 왜 이렇게 설계하는가를 생각하게 된 것 같아 의미있는 시간이었다.

0개의 댓글