단일 책임 원칙 (Single Responsibility Principle - SRP)

  • 의미: 클래스는 단 하나의 책임만 가져야 한다. 하나의 클래스가 하나의 변경 이유만 가져야 한다.
  • 적용여부 : 변경 시 코드의 수정이 많이 않으면 제대로 SRP를 적용한 것
  • 장점:
    • 코드 변경 시 영향 범위 축소
    • 코드 가독성 증가 및 유지보수 용이
  • 코드 예시:
    // 잘못된 예: User 클래스가 데이터 관리와 이메일 발송 두 가지 책임을 가짐
    class User {
        public void saveToDatabase() {
            // DB 저장 로직
        }
        public void sendWelcomeEmail() {
            // 이메일 발송 로직
        }
    }
    
    // 개선된 예: 책임 분리
    class User {
        public void saveToDatabase() {
            // DB 저장 로직
        }
    }
    
    class EmailService {
        public void sendWelcomeEmail(User user) {
            // 이메일 발송 로직
        }
    }
    

 

개방-폐쇄 원칙 (Open-Closed Principle - OCP)

  • 의미: 확장에는 열려 있어야 하고, 수정에는 닫혀 있어야 한다. 새로운 기능 추가 시 기존 코드를 수정하지 않도록 설계해야 한다.
  • 확장하려면 변경이 필요하다고 생각하지만, 다형성을 생각해보자, 베이스가 되는 인터페이스를 토대로 새로운 클래스를 만들면 변경 없이도 확장이 가능하다.
  • 다만, 결국 객체를 변경하면 OCP 원칙을 지킬 수 없다. 이 경우 객체를 생성하고 연관 관계를 맺어주는 별도의 조립, 설정자가 필요하다.
더보기

OCP(개방-폐쇄 원칙)를 유지하려면 객체의 생성과 연관 관계 설정을 코드 내부가 아니라 별도의 설정자(조립자)에서 처리해야 합니다. 이렇게 하면 객체를 변경하지 않고도 동작을 유연하게 확장할 수 있습니다. 이를 이해하기 위한 예제를 단계별로 설명하겠습니다.


문제 상황: 객체 내부에서 다른 객체를 직접 생성

아래 코드는 OCP 원칙을 위반한 예입니다. 객체를 수정하지 않고는 다른 동작으로 확장할 수 없습니다.

class NotificationService {
    private EmailSender emailSender;

    public NotificationService() {
        this.emailSender = new EmailSender(); // 객체를 직접 생성
    }

    public void sendNotification(String message) {
        emailSender.send(message);
    }
}

class EmailSender {
    public void send(String message) {
        System.out.println("Sending Email: " + message);
    }
}

// 사용 코드
NotificationService notificationService = new NotificationService();
notificationService.sendNotification("Hello!");

문제점:

  • NotificationService가 EmailSender에 강하게 결합되어 있음.
  • 새로운 알림 수단(예: SmsSender, PushSender)을 추가하려면 NotificationService를 수정해야 함 → OCP 위반.

해결책: 별도의 조립자(설정자) 사용

객체를 직접 생성하지 않고 외부에서 주입받도록 변경하면 OCP 원칙을 지킬 수 있습니다. 아래는 이를 구현한 예제입니다.

1. 인터페이스를 사용해 역할(알림 수단) 정의

interface NotificationSender {
    void send(String message);
}

class EmailSender implements NotificationSender {
    @Override
    public void send(String message) {
        System.out.println("Sending Email: " + message);
    }
}

class SmsSender implements NotificationSender {
    @Override
    public void send(String message) {
        System.out.println("Sending SMS: " + message);
    }
}

2. NotificationService가 인터페이스에 의존하도록 수정

class NotificationService {
    private NotificationSender sender;

    // 생성자를 통해 객체를 주입받음
    public NotificationService(NotificationSender sender) {
        this.sender = sender;
    }

    public void sendNotification(String message) {
        sender.send(message);
    }
}

3. 조립자(설정자) 역할을 수행하는 클래스

조립자는 객체 생성과 의존성 주입을 담당합니다.

class NotificationConfigurator {
    public static NotificationService configure(String type) {
        NotificationSender sender;

        // 필요한 구현 객체를 결정하고 주입
        if ("email".equalsIgnoreCase(type)) {
            sender = new EmailSender();
        } else if ("sms".equalsIgnoreCase(type)) {
            sender = new SmsSender();
        } else {
            throw new IllegalArgumentException("Unknown notification type: " + type);
        }

        // NotificationService에 주입
        return new NotificationService(sender);
    }
}

4. 사용 코드

public class Main {
    public static void main(String[] args) {
        // 알림 방식 선택
        NotificationService notificationService = NotificationConfigurator.configure("sms");

        // 알림 전송
        notificationService.sendNotification("Hello via SMS!");
    }
}

설명

  1. 조립자(설정자) 역할: NotificationConfigurator 클래스가 객체 생성과 의존성 주입을 담당.
    • 이제 NotificationService는 구체적인 구현(EmailSender, SmsSender)을 몰라도 됨.
    • 새로운 알림 수단 추가 시, 기존 클래스(NotificationService)를 수정할 필요 없음.
      예: PushSender를 추가하려면, NotificationSender를 구현하고, NotificationConfigurator에만 추가하면 됨.
  2. OCP 원칙 준수:
    • NotificationService는 인터페이스(NotificationSender)에 의존하므로 확장이 용이.
    • 새로운 알림 방식 추가 시 기존 코드를 수정하지 않고도 확장이 가능.
  3. 스프링의 활용: 스프링 프레임워크는 이 조립자 역할을 DI(의존성 주입) 컨테이너가 대신 수행해 더욱 편리하게 다형성을 활용할 수 있도록 지원.

결론

객체의 생성과 의존성 주입을 별도의 조립자에서 처리하면 OCP 원칙을 지킬 수 있으며, 클래스의 확장성은 높아지고 변경 비용은 줄어듭니다. 이는 유지보수와 협업에 있어 큰 장점이 됩니다.

  • 장점:
    • 새로운 기능 추가 시 기존 코드 변경 최소화
    • 코드 안정성과 확장성 증가
  • 코드 예시:
    // 잘못된 예: 각 도형마다 개별 로직 추가 필요
    class AreaCalculator {
        public double calculateArea(Object shape) {
            if (shape instanceof Circle) {
                Circle circle = (Circle) shape;
                return Math.PI * circle.radius * circle.radius;
            } else if (shape instanceof Rectangle) {
                Rectangle rectangle = (Rectangle) shape;
                return rectangle.width * rectangle.height;
            }
            return 0;
        }
    }
    
    // 개선된 예: 다형성을 활용해 확장 가능
    interface Shape {
        double calculateArea();
    }
    
    class Circle implements Shape {
        double radius;
        public Circle(double radius) {
            this.radius = radius;
        }
        public double calculateArea() {
            return Math.PI * radius * radius;
        }
    }
    
    class Rectangle implements Shape {
        double width, height;
        public Rectangle(double width, double height) {
            this.width = width;
            this.height = height;
        }
        public double calculateArea() {
            return width * height;
        }
    }
    
    class AreaCalculator {
        public double calculateArea(Shape shape) {
            return shape.calculateArea();
        }
    }
    

 

리스코프 치환 원칙 (Liskov Substitution Principle - LSP)

  • 의미: 자식 클래스는 부모 클래스의 행위를 대체할 수 있어야 한다. 즉, 부모 클래스 타입의 객체를 자식 클래스 객체로 대체해도 동작해야 한다.
  • 장점:
    • 코드 재사용성 향상
    • 다형성 원칙 준수
  • 코드 예시:
// 잘못된 예: 자식 클래스가 부모 클래스의 기대를 위반
class Rectangle {
    int width, height;
    public void setWidth(int width) {
        this.width = width;
    }
    public void setHeight(int height) {
        this.height = height;
    }
    public int getArea() {
        return width * height;
    }
}

class Square extends Rectangle {
    @Override
    public void setWidth(int width) {
        this.width = this.height = width;
    }
    @Override
    public void setHeight(int height) {
        this.width = this.height = height;
    }
}

// 사용 시 문제 발생
Rectangle rect = new Square();
rect.setWidth(5);
rect.setHeight(10);
System.out.println(rect.getArea()); // 잘못된 결과: 100 (정사각형 논리에 의해 동작)

 

 

인터페이스 분리 원칙 (Interface Segregation Principle - ISP)

  • 의미: 클라이언트는 사용하지 않는 메서드에 의존하지 않아야 한다. 인터페이스는 작고 구체적으로 분리해야 한다.
  • 장점:
    • 불필요한 의존성 제거
    • 인터페이스 변경 시 영향 최소화
  • 코드 예시:
    // 잘못된 예: 하나의 인터페이스에 불필요한 메서드 포함
    interface Printer {
        void print();
        void scan();
        void fax();
    }
    
    class BasicPrinter implements Printer {
        public void print() {
            // 프린트 기능 구현
        }
        public void scan() {
            throw new UnsupportedOperationException("Scan not supported");
        }
        public void fax() {
            throw new UnsupportedOperationException("Fax not supported");
        }
    }
    
    // 개선된 예: 인터페이스를 분리
    interface Printable {
        void print();
    }
    interface Scannable {
        void scan();
    }
    interface Faxable {
        void fax();
    }
    
    class BasicPrinter implements Printable {
        public void print() {
            // 프린트 기능 구현
        }
    }
    

 

의존 역전 원칙 (Dependency Inversion Principle - DIP)

  • 의미: 고수준 모듈(비즈니스 로직)은 저수준 모듈(구체적 구현)에 의존해서는 안 된다. 둘 다 추상화된 인터페이스에 의존해야 한다.
  • 단순히 말하면 구현(구체화) 클래스 말고 인터페이스(추상화)에 의존해서 코딩해라라는 것, 역할에 의존해야만 다형성을 잃지않고 확장이 잘되는 프로그램을 구성할 수 있다.
  • 장점:
    • 의존성 역전으로 모듈 간 결합도 감소
    • 유연성과 테스트 용이성 증가
  • 코드 예시:
    // 잘못된 예: 고수준 모듈이 저수준 모듈에 의존
    class MySQLDatabase {
        public void connect() {
            System.out.println("Connected to MySQL Database");
        }
    }
    
    class UserService {
        private MySQLDatabase database;
    
        public UserService() {
            this.database = new MySQLDatabase();
        }
    
        public void performDatabaseOperation() {
            database.connect();
        }
    }
    
    // 개선된 예: 인터페이스를 통한 의존성 역전
    interface Database {
        void connect();
    }
    
    class MySQLDatabase implements Database {
        public void connect() {
            System.out.println("Connected to MySQL Database");
        }
    }
    
    class PostgreSQLDatabase implements Database {
        public void connect() {
            System.out.println("Connected to PostgreSQL Database");
        }
    }
    
    class UserService {
        private Database database;
    
        public UserService(Database database) {
            this.database = database;
        }
    
        public void performDatabaseOperation() {
            database.connect();
        }
    }
    

 

요약 표

원칙  의미  장점  위반 시 문제
단일 책임 원칙 (SRP) 클래스는 하나의 책임만 가져야 함 변경 범위 축소, 가독성 및 유지보수성 향상 여러 이유로 클래스가 자주 변경되며, 코드가 복잡해짐
개방-폐쇄 원칙 (OCP) 확장에는 열려 있고, 수정에는 닫혀 있어야 함 안정성과 확장성 증가 새로운 기능 추가 시 기존 코드를 반복적으로 수정해야 함
리스코프 치환 원칙 (LSP) 자식 클래스가 부모 클래스의 행위를 대체할 수 있어야 함 재사용성 및 다형성 유지 자식 클래스가 부모 클래스의 기대를 깨뜨려 예상치 못한 결과 발생
인터페이스 분리 원칙 (ISP) 클라이언트는 필요하지 않은 메서드에 의존하지 않아야 함 불필요한 의존성 제거, 인터페이스 변경 영향 최소화 불필요한 메서드로 인해 코드가 복잡하고 유연성이 떨어짐
의존 역전 원칙 (DIP) 고수준 모듈은 저수준 모듈에 의존하지 말고, 인터페이스에 의존해야 함 결합도 감소, 테스트 용이성 증가 고수준 모듈이 구현체에 의존해 유연성과 테스트 가능성 상실

 

 

 

'Project > JAVA' 카테고리의 다른 글

[JAVA] 키오스크  (0) 2024.11.26
[JAVA] 계산기를 만들어보자!  (0) 2024.11.13

트러블 슈팅

 

1. 배경

  • 전에 했던 과제를 반복해서 좀더 현업에서 진행하는 것과 같은 형식의 코딩을 진행하였다.
  • 기본적으로 자바 기능을 재 학습하는 목적으로 사용된 프로젝트였다.

2. 발단

  • 기본적으로 눈에 띄는 에러는 없었다.
  • 다만 코드가 요구사항이 길어짐으로서 복잡해지는 점이 문제가 되었다.

3. 전개

 

  • 기본적으로 코드가 길어지고 클래스가 늘면서 위와 같이 한 폴더에 7개의 파일이 생성되게 되었다.
  • 많은 수는 아닐지 모르지만 가장 중요한 점은, 이 파일이 100개가 넘어가면 한 폴더에서 다 처리하기엔 가시성이 너무 떨어질 수 있다는 점이다.
  • 두번째는 코드의 조건문의 형식으로 인해 가시성이 많이 떨어져 보인다.
  • 실제로 객체 지향적인 코딩을 진행했지만, 이것만으로는 부족한지 내용에 수정이 필요해 보인다.

 

4. 위기

  • 역시 폴더를 임의로 바꾸니 에러가 나기 시작했다.
  • 내용은 패키지의 지정이 되어 있지 않아 생기는 오류였다.

5. 절정

  • 검색해 보니 단순히 패키지 지정을 해주지 않아서 생기는 오류였다.
  • 실제로 코드가 복잡해지고 나면 이런 저장소 위치나 수정 일시 등의 정보는 자주 기록하는 것이 좋다.

 

6. 결말

  • 기본적으로 사용했던 기능을 복습하는 코드였다보니 어려운 응용은 없었지만, 아직 파일의 분리와 가시성에 대한 부분이 자연스럽게 진행되지 못한 다는 것을 깨달았다.
  • 이는 유지보수에도 관련이 있지만 AWS와 같은 외부 요인의 업데이트로 발생하는 오류를 처리하는데도 도움을 줄 수 있다.
  • SOLID원칙 객체지향 프로그래밍 등, 클린 코드를 위한 기법은 다양하게 많고 실제로 모든 상황에 앞선 기술들이 적용되진 못한다는 것을 감안하면, 앞으로의 코딩은 수행 - 검수 - 수정의 과정을 거쳐야할 것 같다.
  • 즉, 스승이 필요하다..

 

7. 회고

  • 확실히 전 보다 감이 오는 부분은 객체지향인 것 같다.
  • 실제로 이 코드가 완전한 객체 지향을 수행한 것은 아니겠지만, 적어도 왜 객체지향이 필요하고 왜 요구사항이 중요한지는 파악할 수 있었다.
  • 정확한 설계가 있어야 추후 문제가 없다. 완벽한 설계는 무리일지언정, 적어도 필요한 기능과 구현에 대한 정리는 머리속에 있어야한다는 것
  • 이게 완료되면 다양한 설계 패턴과 디자인 패턴을 이용하여 최대한 코드를 알아보기 쉽게 짤 수 있어야한다.
  • 이 과정에서 어노테이션과 주석이 정말 중요한 역활을 할 수 있다고 생각하게 되었다.
  • 주석이라는 것이, 혼자 코딩할때는 대강 기능에 대한 설명을 단촐하게 서술하는 경우가 많았지만, 이렇게하면 추후 알아보기 힘들어질 수 있다.
  • 실제로 혼자서 2000줄정도 넘어가면 코드를 짜던 나 조차도 이게 뭐지 하는 경우가 많이 있었다.
  • 스프링은 특히 어노테이션으로 이루어져 가시성도 좋은 만큼 많이 활용할 수 있으면 장점이 클 것 같았다.
  • 과거의 프로젝트도 이런 식으로 진행했으면 아마, 지금에 와서 다시 수정해서 사용해볼 수도 있었을 것 같다.
  • 다만 셀레니움처럼 외부의 데이터가 자주 변경되는 경우 대응이 힘든 코드는 대체 어떻게 관리를 하는건지 여전히 모르겠다는 생각이 든다.
  • 다음에는 이 유지보수 방법에 대해 깊게 공부하는 편이 좋을 것 같다.

 

 

'Project > JAVA' 카테고리의 다른 글

[JAVA] SOLID 원칙  (1) 2025.01.23
[JAVA] 계산기를 만들어보자!  (0) 2024.11.13

요구사항 정의 및 설계

  1. 요구사항 정의
    • 어떤 계산기 기능이 필요한지 명확히 합니다.
    • 예를 들어, 기본적인 사칙연산(덧셈, 뺄셈, 곱셈, 나눗셈), 괄호 사용, 제곱근, 제곱 등.
    • 사용자 인터페이스 방식 결정 : 콘솔 기반인지, GUI(그래픽 사용자 인터페이스) 기반인지.
    • 예외 처리 : 0으로 나누기와 같은 오류를 어떻게 처리할지 결정합니다.
  2. 설계
    • 클래스 다이어그램: 계산기에 필요한 클래스를 설계합니다. 예를 들어, Calculator, Operation, Parser 등이 될 수 있습니다.
    • 기능 분해: 주요 기능을 메소드로 분리합니다. 예를 들어, add(), subtract(), multiply(), divide(), evaluateExpression() 등.
    • 데이터 흐름 설계: 데이터가 클래스 간에 어떻게 흐를지를 결정합니다. 입력, 처리, 출력의 흐름을 이해합니다.
요구사항
LV 1
1. 양의 정수(0 포함)를 입력받기
2. 사칙연산 기호(,,✖️,)를 입력받기
3. 위에서 입력받은 양의 정수 2개와 사칙연산 기호를 사용하여 연산을 진행한 후 결과값을 출력하기
4. 반복문을 사용하되, 반복의 종료를 알려주는 “exit” 문자열을 입력하기 전까지 무한으로 계산을 진행할 수 있도록 소스 코드를 수정하기


LV 2
1. App 클래스의 main 메서드에 Calculator 클래스가 활용될 수 있도록 수정
2. App 클래스의 main 메서드에서 Calculator 클래스의 연산 결과를 저장하고 있는 컬렉션 필드에 직접 접근하지 못하도록 수정 (캡슐화)


LV 3
1. 계산된 결과 값들을 기록
2. 컬렉션의 가장 먼저 저장된 데이터를 삭제
3. 양의 정수만 받았지만 이제부터는 실수도 받을수 있게 수정
4. 결과가 저장되어 있는 컬렉션을 조회하는 기능을 만든다.
5. 그때 특정 값 보다 큰 결과 값을 출력 할 수 있도록.
설계( 클래스 다이어그램 )
클래스 다이어그램
Calculator : 계산의 전반적인 부분을 담당
Main : 반복문으로 실제 순회와 화면 표시등, 기능의 통합과 출력을 담당
Folder : 데이터를 저장하는 공간을 창출
Option : 계산의 조건을 담당

설계
Option
Calculator
Folder
Main
설계( 기능 분해  )
Calculator : 계산의 전반적인 부분을 담당
- add(), subtract(), multiply(), divide()

Main : 반복문으로 실제 순회와 화면 표시등, 기능의 통합과 출력을 담당

Folder : 데이터를 저장하는 공간을 창출
- save content (), removecontent()

Operator : 계산의 조건을 담당
- caloption()

설계(  데이터 흐름 설계 )
1. 입력 
- 사용자 데이터 입력 -> Main에서 요청
2. 처리
- Calculator 클래스 객체 생성 -> 계산 -> Folder -> 데이터 저장, 수정, 출력
3. 출력
-  Main 출력
더보기
더보기

### 1. **클래스 다이어그램**

각각의 클래스의 역할과 관계를 명확히 하고, 이를 바탕으로 클래스를 설계해 보겠습니다.

#### 주요 클래스 및 관계
1. **Option (부모 클래스)**  
   - **역할**: 계산기의 설정 및 옵션을 관리합니다.  
   - **속성**:
     - `operator`: 계산에 사용할 연산자(예: +, -, *, /)
   - **메소드**:
     - `getOperator()`: 현재 연산자 반환
     - `setOperator(String operator)`: 연산자 설정

2. **Calculator (자식 클래스, Option을 상속)**  
   - **역할**: 실제 계산을 담당하는 클래스입니다. `Option` 클래스를 상속받아 연산자와 계산을 처리합니다.  
   - **속성**:
     - `result`: 계산된 결과값
   - **메소드**:
     - `add(double a, double b)`: 덧셈
     - `subtract(double a, double b)`: 뺄셈
     - `multiply(double a, double b)`: 곱셈
     - `divide(double a, double b)`: 나눗셈
     - `evaluateExpression(double a, double b)`: 두 숫자와 연산자로 계산을 처리

3. **Folder (데이터 저장 클래스)**  
   - **역할**: 계산된 결과를 저장하고 관리하는 클래스입니다.  
   - **속성**:
     - `history`: 계산 결과를 저장하는 리스트나 컬렉션
   - **메소드**:
     - `storeResult(double result)`: 계산된 결과를 저장
     - `getHistory()`: 저장된 결과 반환
     - `removeOldestResult()`: 가장 오래된 결과를 삭제

4. **Main (메인 클래스)**  
   - **역할**: 프로그램의 실행과 사용자와의 상호작용을 담당합니다. 반복문을 사용해 계속해서 계산을 수행할 수 있게 합니다.  
   - **속성**:
     - `calculator`: `Calculator` 객체
     - `folder`: `Folder` 객체
     - `parser`: `Parser` 객체
   - **메소드**:
     - `runCalculator()`: 계산기 실행
     - `displayResults()`: 결과를 화면에 출력
     - `exitProgram()`: 프로그램 종료

5. **Parser (입력 처리 클래스)**  
   - **역할**: 사용자 입력을 검증하고 파싱하는 역할을 합니다.  
   - **속성**: 없음
   - **메소드**:
     - `parseInput(String input)`: 사용자 입력을 분석하여 유효성 검사 및 필요한 데이터 반환
     - `isValidOperator(String operator)`: 연산자가 유효한지 검사
     - `isValidNumber(String number)`: 숫자 유효성 검사

---

### 2. **기능 분해**

각각의 클래스가 수행하는 주요 기능을 메소드로 분해하면 다음과 같습니다:

1. **Option 클래스**:
   - `getOperator()`: 계산에 사용할 연산자 반환
   - `setOperator(String operator)`: 연산자 설정

2. **Calculator 클래스** (Option을 상속받음):
   - `add(double a, double b)`: 덧셈 수행
   - `subtract(double a, double b)`: 뺄셈 수행
   - `multiply(double a, double b)`: 곱셈 수행
   - `divide(double a, double b)`: 나눗셈 수행 (0으로 나누기 예외 처리 포함)
   - `evaluateExpression(double a, double b)`: 주어진 두 숫자와 연산자로 계산을 실행

3. **Folder 클래스**:
   - `storeResult(double result)`: 계산된 결과를 저장
   - `getHistory()`: 저장된 결과를 반환
   - `removeOldestResult()`: 가장 오래된 계산 결과를 제거

4. **Main 클래스**:
   - `runCalculator()`: 계산기 실행 (무한 루프)
   - `displayResults()`: 저장된 결과 출력
   - `exitProgram()`: 프로그램 종료

5. **Parser 클래스**:
   - `parseInput(String input)`: 사용자 입력을 분석하여 유효한 숫자와 연산자 추출
   - `isValidOperator(String operator)`: 연산자 검증 (예: `+`, `-`, `*`, `/`)
   - `isValidNumber(String number)`: 숫자 유효성 검사 (예: 실수 또는 정수인지 확인)

---

### 3. **데이터 흐름 설계**

데이터 흐름 설계는 각 클래스 간에 데이터가 어떻게 이동하고 처리되는지 보여줍니다.

#### 데이터 흐름 단계
1. **사용자 입력**:
   - 사용자는 `Main` 클래스에서 계산식을 입력합니다. 예: `5 + 3`.
   
2. **입력 파싱 및 검증**:
   - `Main` 클래스는 입력값을 **Parser** 클래스에 전달합니다.
   - **Parser** 클래스는 입력값을 분석하여 연산자(`+`, `-`, `*`, `/`)와 숫자 두 개를 분리합니다.
   - 숫자와 연산자에 대한 유효성 검사를 진행합니다.
   
3. **연산 처리**:
   - **Main** 클래스는 파싱된 입력을 **Calculator** 클래스에 전달합니다.
   - **Calculator** 클래스는 연산자를 바탕으로 해당 연산을 실행하고, 결과를 반환합니다.
   
4. **결과 저장**:
   - **Calculator** 클래스는 계산된 결과를 **Folder** 클래스에 저장합니다.
   - **Folder** 클래스는 결과를 `history` 컬렉션에 저장하고, 이를 관리합니다.
   
5. **결과 출력**:
   - **Main** 클래스는 `Folder` 클래스에서 계산된 결과를 조회하고, 출력합니다.
   
6. **계속 진행**:
   - 사용자가 `"exit"`을 입력할 때까지 반복적으로 계산을 수행하고 결과를 출력합니다.

---

### **전체 흐름 요약**

1. **사용자 입력** → `Main` → **Parser** (입력 파싱 및 검증)
2. **Parser** → `Operation` 객체 생성 → **Calculator** (연산 처리)
3. **Calculator** → 결과 반환 → `Folder` (결과 저장)
4. **Folder** → 저장된 결과 조회 → `Main` (결과 출력)
5. **반복**: 사용자가 `"exit"`을 입력하기 전까지 계산 반복

---

위 설계를 바탕으로, 프로그램의 각 클래스가 어떻게 협력하여 계산을 수행하고 데이터를 처리하는지를 명확히 할 수 있습니다.

 

 

챗 GPT의 답인데 인간이 필요 없는거 아닌가...

 

< MAIN >

import java.util.List;
import java.util.Scanner;

public class Main {
    public static void main(String[] args) {
        double result = 0.0;
        String input1, input2, input3, operator;
        Scanner scanner = new Scanner(System.in);
        Calculator calc = new Calculator();
        DataManager dataManager = new DataManager(); // 데이터 매니저 인스턴스 생성

        while (true) {
            try {
                System.out.print("Enter first number or command (exit/delete data/find data): ");
                input1 = scanner.nextLine();

                // break 문을 통해 루프를 중지합니다.
                if (input1.equalsIgnoreCase("exit")) {
                    System.out.println("Exiting the program.");
                    break;
                }

                if (input1.equalsIgnoreCase("delete data")) {
                    System.out.println("Deleting all data...");
                    dataManager.clearAllData(); // 모든 데이터 삭제
                    continue;
                }

                if (input1.equalsIgnoreCase("delete one data")) {
                    System.out.print("Deleting one data: ");
                    input2 = scanner.nextLine();

                    try {
                        double number2 = Double.parseDouble(input2);
                        System.out.println(dataManager.confirm(input1, number2));
                    } catch (NumberFormatException e) {
                        System.out.println("Invalid input. Please enter a valid number.");
                    }
                    continue;
                }

                // 각 상황별 대응을 코딩하였습니다.
                // 추가로 클래스를 이용한 인스턴스를 통해 문제를 해결했습니다.
                if (input1.equalsIgnoreCase("modified data")) { //데이터 수정
                    try {
                        System.out.print("Origin data: ");
                        input2 = scanner.nextLine();
                        double number2 = Double.parseDouble(input2);

                        System.out.print("Modified data: ");
                        input3 = scanner.nextLine();
                        double number3 = Double.parseDouble(input3);

                        System.out.println(dataManager.confirm(input1, number2, number3));
                    } catch (NumberFormatException e) {
                        System.out.println("Invalid input. Please enter valid numbers.");
                    }
                    continue;
                }

                if (input1.equalsIgnoreCase("add data")) {// 데이터 추가
                    System.out.print("Enter add number: ");
                    input2 = scanner.nextLine();

                    try {
                        double number2 = Double.parseDouble(input2);
                        System.out.println(dataManager.confirm(input1, number2));
                    } catch (NumberFormatException e) {
                        System.out.println("Invalid input. Please enter a valid number.");
                    }
                    continue;
                }

                if (input1.equalsIgnoreCase("find data")) {// 데이터 찾기
                    System.out.print("Enter comparison operator (e.g., '>'): ");
                    String findOption = scanner.nextLine();

                    System.out.print("Enter the number to compare with: ");
                    try {
                        double comparisonValue = Double.parseDouble(scanner.nextLine());

                        // 조건에 맞는 데이터 검색 후 출력
                        List<Double> foundData = dataManager.findDataByCondition(findOption, comparisonValue);
                        System.out.println("Found data: " + foundData);
                    } catch (NumberFormatException e) {
                        System.out.println("Invalid input. Please enter a valid number.");
                    }
                    continue;
                }

                // 사측연산 구현
                double number1 = Double.parseDouble(input1);

                System.out.print("Enter second number: ");
                input2 = scanner.nextLine();
                double number2 = Double.parseDouble(input2);

                System.out.print("Enter an operator (+, -, *, /): ");
                operator = scanner.nextLine();

                try {
                    Operator operatorEnum = Operator.fromString(operator);

                    result = calc.calculate(number1, number2, operatorEnum);

                    System.out.println("Result: " + result);

                    // 결과를 DataManager에 저장
                    dataManager.addData(result);
                    // 아래는 예외처리입니다. 글자가 입력된 경우, 연산자 오류인경우, 잘못된 산술연산이 발생한 경우, 특정 예외 전부를 탐색하기 위해 Exception클래스를 사용한 전체 예외처리
                } catch (IllegalArgumentException e) {
                    System.out.println("Invalid operator. Please enter one of +, -, *, /.");
                } catch (ArithmeticException e) {
                    System.out.println(e.getMessage());
                }
            } catch (NumberFormatException e) {
                System.out.println("Invalid input. Please enter a valid number.");
            } catch (Exception e) {
                System.out.println("An unexpected error occurred: " + e.getMessage());
            }
        }

        scanner.close();
    }
}

 

 

< Calculator >

public class Calculator {

    public <T extends Number> double calculate(T x, T y, Operator operator) {
        double num1 = x.doubleValue(); // double형 변환
        double num2 = y.doubleValue();

        switch (operator) {
            case ADD:
                return num1 + num2;
            case SUBTRACT:
                return num1 - num2;
            case MULTIPLY:
                return num1 * num2;
            case DIVIDE:
                if (num2 == 0) {
                    throw new ArithmeticException("Cannot divide by zero");
                }
                return num1 / num2;
            default:
                throw new IllegalArgumentException("Unsupported operator: " + operator);
        }
    }
}

< DataManager >

import java.util.ArrayList;
import java.util.List;
import java.util.stream.Collectors;

public class DataManager {
    private List<Double> dataList;

    public DataManager() {
        this.dataList = new ArrayList<>();
    }

    // 데이터 추가
    public void addData(double data) {
        dataList.add(data);
    }

    // 조건에 맞는 데이터를 찾기 (>, <, = 조건) using Streams
    public List<Double> findDataByCondition(String condition, double value) {
        return dataList.stream()
                .filter(data -> {
                    switch (condition) {
                        case ">": return data > value;
                        case "<": return data < value;
                        case "=": return data == value;
                        default:
                            System.out.println("Invalid condition. Use >, <, or =.");
                            return false;
                    }
                })
                .collect(Collectors.toList());
    }

    // `add data` 및 `delete one data` 작업을 위한 메서드 (제네릭 사용)
    public <T extends Number> String confirm(String condition, T value) {
        switch (condition) {
            case "add data":
                dataList.add(value.doubleValue());
                return "Data added: " + value.doubleValue();

            case "delete one data":
                // 람다, 데이터 삭제
                boolean removed = dataList.removeIf(data -> data == value.doubleValue());
                return removed ? "Complete delete one data" : "Data not found for deletion";

            default:
                return "Unknown condition: " + condition;
        }
    }

    // `modify data` 작업을 위한 메서드 (제네릭 사용)
    public <T extends Number> String confirm(String condition, T value, T value2) {
        if ("modified data".equals(condition)) {
            for (int i = 0; i < dataList.size(); i++) {
                if (dataList.get(i) == value.doubleValue()) {
                    dataList.set(i, value2.doubleValue());
                    return "Complete data modify: " + value + " -> " + value2;
                }
            }
        }
        return "Condition not found for modification.";
    }

    // 모든 데이터 삭제
    public void clearAllData() {
        dataList.clear();
        System.out.println("All data has been deleted.");
    }
}

< Operator >

public enum Operator { // 스트림을 통한 사측연산 구현
    ADD("+"),
    SUBTRACT("-"),
    MULTIPLY("*"),
    DIVIDE("/");

    private final String symbol;

    Operator(String symbol) {
        this.symbol = symbol;
    }

    public String getSymbol() {
        return symbol;
    }

    public static Operator fromString(String symbol) {
        for (Operator operator : Operator.values()) {
            if (operator.getSymbol().equals(symbol)) {
                return operator;
            }
        }
        throw new IllegalArgumentException("Invalid operator: " + symbol);
    }
}

 

 

 

구현

Lv 1. 클래스 없이 기본적인 연산을 수행할 수 있는 계산기 만들기

  • [ ] 양의 정수(0 포함)를 입력받기
    • [ ] Scanner를 사용하여 양의 정수 2개(0 포함)를 전달 받을 수 있습니다.
    • [ ] 양의 정수는 각각 하나씩 전달 받습니다.
    • [ ] 양의 정수는 적합한 타입으로 선언한 변수에 저장합니다.
  • [ ] 사칙연산 기호(➕,➖,✖️,➗)를 입력받기
    • [ ] Scanner를 사용하여 사칙연산 기호를 전달 받을 수 있습니다.
    • [ ] 사칙연산 기호를 적합한 타입으로 선언한 변수에 저장합니다. (charAt(0))
  • [ ] 위에서 입력받은 양의 정수 2개와 사칙연산 기호를 사용하여 연산을 진행한 후 결과값을 출력하기
    • [ ] 키워드 : if switch
    • [ ] 사칙연산 기호에 맞는 연산자를 사용하여 연산을 진행합니다.
    • [ ] 입력받은 연산 기호를 구분하기 위해 제어문을 사용합니다. (예를 들면 if, switch)
    • [ ] 연산 오류가 발생할 경우 해당 오류에 대한 내용을 정제하여 출력합니다.
      • [ ] ex) “나눗셈 연산에서 분모(두번째 정수)에 0이 입력될 수 없습니다.“
  • [ ] 반복문을 사용하되, 반복의 종료를 알려주는 “exit” 문자열을 입력하기 전까지 무한으로 계산을 진행할 수 있도록 소스 코드를 수정하기
    • [ ] 키워드 : 무한으로 반복, 수정하기 (처음부터 무한 반복하는 것이 아니라, 위 스텝별로 진행하며 수정)
    • [ ] 반복문을 사용합니다. (예를 들어, for, while…)

Lv 2. 클래스를 적용해 기본적인 연산을 수행할 수 있는 계산기 만들기

  • [ ] 사칙연산을 수행 후, 결과값 반환 메서드 구현 & 연산 결과를 저장하는 컬렉션 타입 필드를 가진 Calculator 클래스를 생성
    • [ ] 사칙연산을 수행한 후, 결과값을 반환하는 메서드 구현
    • [ ] 연산 결과를 저장하는 컬렉션 타입 필드를 가진 Calculator 클래스를 생성
    • [ ] 1) 양의 정수 2개(0 포함)와 연산 기호를 매개변수로 받아 사칙연산(➕,➖,✖️,➗) 기능을 수행한 후 2) 결과 값을 반환하는 메서드와 연산 결과를 저장하는 컬렉션 타입 필드를 가진 Calculator 클래스를 생성합니다.
  • [ ] Lv 1에서 구현한 App 클래스의 main 메서드에 Calculator 클래스가 활용될 수 있도록 수정
    • [ ] 연산 수행 역할은 Calculator 클래스가 담당
      • [ ] 연산 결과는 Calculator 클래스의 연산 결과를 저장하는 필드에 저장
    • [ ] 소스 코드 수정 후에도 수정 전의 기능들이 반드시 똑같이 동작해야합니다.
  • [ ] App 클래스의 main 메서드에서 Calculator 클래스의 연산 결과를 저장하고 있는 컬렉션 필드에 직접 접근하지 못하도록 수정 (캡슐화)
    • [ ] 간접 접근을 통해 필드에 접근하여 가져올 수 있도록 구현합니다. (Getter 메서드)
    • [ ] 간접 접근을 통해 필드에 접근하여 수정할 수 있도록 구현합니다. (Setter 메서드)
    • [ ] 위 요구사항을 모두 구현 했다면 App 클래스의 main 메서드에서 위에서 구현한 메서드를 활용 해봅니다.
  • [ ] Calculator 클래스에 저장된 연산 결과들 중 가장 먼저 저장된 데이터를 삭제하는 기능을 가진 메서드를 구현한 후 App 클래스의 main 메서드에 삭제 메서드가 활용될 수 있도록 수정
    • [ ] 키워드 : 컬렉션
      • [ ] 컬렉션에서 ‘값을 넣고 제거하는 방법을 이해한다.’가 중요합니다!

도전 기능 가이드

3. Enum, 제네릭, 람다 & 스트림을 이해한 계산기 만들기

  • [ ] 현재 사칙연산 계산기는 (➕,➖,✖️,➗) 이렇게 총 4가지 연산 타입으로 구성되어 있습니다.
    • [ ] Enum 타입을 활용하여 연산자 타입에 대한 정보를 관리하고 이를 사칙연산 계산기 ArithmeticCalculator 클래스에 활용 해봅니다.
  • [ ] 실수, 즉 double 타입의 값을 전달 받아도 연산이 수행하도록 만들기
    • [ ] 키워드 : 제네릭
      • [ ] 단순히, 기존의 Int 타입을 double 타입으로 바꾸는 게 아닌 점에 주의하세요!
    • [ ] 지금까지는 ArithmeticCalculator, 즉 사칙연산 계산기는 양의 정수(0 포함)를 매개변수로 전달받아 연산을 수행
    • [ ] 피연산자를 여러 타입으로 받을 수 있도록 기능을 확장
      • [ ] ArithmeticCalculator 클래스의 연산 메서드(calculate)
    • [ ] 위 요구사항을 만족할 수 있도록 ArithmeticCalculator 클래스를 수정합니다. (제네릭)
      • [ ] 추가적으로 수정이 필요한 다른 클래스나 메서드가 있다면 같이 수정 해주세요.
  • [ ] 저장된 연산 결과들 중 Scanner로 입력받은 값보다 큰 결과값 들을 출력
    • [ ] ArithmeticCalculator 클래스에 위 요구사항을 만족하는 조회 메서드를 구현합니다.
    • [ ] 단, 해당 메서드를 구현할 때 Lambda & Stream을 활용하여 구현합니다.
      • [ ] Java 강의에서 람다 & 스트림을 학습 및 복습 하시고 적용 해보세요!
    • [ ] 추가) 람다 & 스트림 학습을 위해 여러 가지 조회 조건들을 추가하여 구현 해보시면 학습에 많은 도움이 되실 수 있습니다.

하고 싶은대로 가이드

3. 오버로드, 제네릭 + 정형 타입 매개변수 사용

  • 오버로드를 사용하여 여러 함수를 구현해본다.
  • 제네릭 타입을 포함한 다양한 매개변수를 하나의 메소드에서 사용해 본다
  • 특정 데이터의 추가, 삭제, 수정등의 추가 기능을 구현한다.
  • 예외처리

 

 

 

트러블 슈팅

배경 : 계산기 과제를 받고 위의 내용을 기준으로 프로젝트 제작을 진행했다.

발단 

  • 오랜만에 자바 프로젝트를 진행해보니 파이썬과 너무 많은 차이가 있었다.
  • 알고리즘 적인 요소는 잘 이해하고 있다보니 수월하게 해결할 수 있었다.
  • 클래스 2개를 제작하여 하나는 계산, 정보 관리를 구현하였다.
  • 다만 여기서 문제가 발생했는데
  • 제니릭과 추상화된 타입의 연산자간의 문제가 발생했다.

전개 

  • 이 과제의 해결법은 크게 2가지였다.
  • 하나, 제니릭 데이터의 반환값을 미리 정해두는 방식
  • 둘, 제니릭한 데이터 매개변수의 타입을 for문으로 하나씩 다 확인하는 방법
  • 이 과정에서 첫번째 방법을 택하게 되었는데 이유는 코드가 더 간결하게 짤 수 있어서였다.
  • 조금 더 자세히 설명하면, Number 클래스를 상속받으면 해당 클래스 메소드인 doubleValue()를 사용해 매개변수 타입을 임의로 지정할 수 있었기 때문이었다.
class Calculator {

    // 덧셈
    public <T extends Number> double add(T x, T y) {
        return x.doubleValue() + y.doubleValue();
    }

    // 뺄셈
    public <T extends Number> double subtract(T x, T y) {
        return x.doubleValue() - y.doubleValue();
    }

    // 곱셈
    public <T extends Number> double multiply(T x, T y) {
        return x.doubleValue() * y.doubleValue();
    }

    // 나눗셈
    public <T extends Number> double divide(T x, T y) {
        // 0으로 나누는 경우 예외 처리
        if (y.doubleValue() == 0) {
            throw new ArithmeticException("Cannot divide by zero");
        }
        return x.doubleValue() / y.doubleValue();
    }
}

 

위기 

  • 다만 코드를 진행하면서 좀더 오려운 방식으로 코드를 전개해 보고 싶어졌다.
  • 이번에는 제네릭을 사용하면서 모든 타입을 입력할 수 있도록하는 매개변수 타입을 만들어보고 싶어졌다.
  • 다만 위 방식은 구현이 애매해졌는데
  • public T void로 선언하면 확실히 모든 타입을 입력할 수 있게 되지만
  • 이 경우 클래스 내부의 연산자를 사용하는 방식이 너무 복잡하고 길어진다는 문제점이 있었다.

절정 : 근본적인 해결을 위해 이런 방법으로 접근하였다.

 

  • 그래서 아쉽지만 해당 방식은 구현을 포기하고 T와 정적 타입 매개변수를 같이 사용하는 것을 연습해 보기로 했다.
  • 이번에는 함수에 특정 데이터 삭제와 수정을 추가할 예정이라 오버로드를 사용할 수 있었다.
  • 코드는 단순히 double 데이터와 int 데이터를 받을 수 있도록 Number 클래스를 상속하는 타입을 만들었고
  • 문자열을 받을 수 있는 String 타입의 매개변수를 받는 class confirm을 제작하였다.
  • 그 외에는 다른 변수 없이 처리가 가능하였다.
  • 조금 의외였던 것은 this.dataList를 안해도 제대로 dataList의 내용을 수정할 수 있었다.
  • 사실 이 부분은 단순히 생각하면 되는데 객체의 dataList를 불러오는 dataList나 현재 객체라는 의미를 가진 this를 추가한 this.dataList나 다를게 없다..
  • 보통 this는 매개 변수와 메서드 내의 변수명이 같을 때 구분하라고 사용하는 건데 현재 코드에는 딱히 맴버,로컬,매개 변수 중 이름이 같은 변수는 없었다.
  • 사실 제네릭, 스트림, 람다 모두 기존의 파이썬에서는 사용하지 않은 기능이다보니 기능들이 필요한 적제 적소의 타이밍이 언제인지 감이 잘 오지 않았다.

 

결말 : 최종적으로 코드는 수월하게 완성이 되었다. 크게 막히는 부분은 없었지만, 몇번 gpt에게 물어보니 코드의 최적화와 클린 코드 작성에 있어서 큰 차이를 보였다. 기능을 구현하는 것도 문론 중요하지만, 어떠한 코드든 소프트웨어 생명주기 모델에 의거하여 유지보수가 거의 90%를 차지할 정도로 코드의 관리 용이성은 중요하다. 그 점에서 오늘 제작한 코드는 미흡한 점이 크다고 생각한다.

  • 변수명이 이해가 되지 않는 부분은 변수 명을 수정하여 가독성을 높였다.
  • 제네릭을 사용하여 다양한 형태의 데이터를 사용할 수 있게 수정했다.
  • 스트림, 람다를 이용해 코드의 가독성을 높였다.
  • 클래스를 나누어 적절히 기능별로 구현하였다.
  • 상속을 구현하기보단 단순히 표현하기 위해 따로 클래스를 구현하였다.
  • for, switch문을 적절히 사용하여 선택지를 구성할 수 있었다.

 

회고

  • 사실 위의 방식은 배운 부분을 유기적으로 연동한 것이 끝이라 어렵지 않았지만 아래의 내용이 앞으로의 숙제일 것 같다.
  • 코드의 변수, 객체, 생성자, 메서드, 매서드 내 변수, 함수 관리에 특정한 룰을 만드는 것이 좋아보인다.
  • 코드가 짧으면 무조건 좋다고 생각했지만 그건 또 아닌 듯 하다.
  • 대부분의 코드를 다른 팀원과 하게되면 항상 말하는 것이 "짜여있는 코드보다 차라리 처음부터 짜는게 빠르겠다" 라는 말을 많이한다. 그 점에 있어 타인과 함께 이해할 수 있는 코드 방식을 구사하는 것이 중요해 보인다.
  • 복잡한 코드에는 주석을 통해 설명을 꼭 해줘야한다는 점도 중요한 포인트라고 생각이 든다.
  • 특히 이번에 while, for, if, switch문을 보면서 생각나는게, 이 조건문 반복문은 꼭 필요하면서 코드를 길게 만드는 점이 좀 마음에 안든다...
  • Map을 사용하는 방식으로 수정해 보는 것이 더 효율적이라는 생각이 든다.
  • 다양한 기능을 배우고 구현을 해봤다고는 하지만, 실제로 정확히 어느 타이밍에 뭘 사용해야하는지, 이게 가장 어려운 것 같다. 적제 적소에 필요한 기능을 유지보수하기 쉽도록 개발하는 것의 어려움을 깨달았다.
  • 스트림을 사용하는 방식으로 간단히 표현한 점에서는 유지보수에 용히하게 개선이 되었지만, 제네릭은 너무 코드를 이해하기 힘들게 만드는 것 같아서 기본 타입으로 구성하는 것이 좋을 것 같다.
  • 상속의 개념은 최고 클래스에서 불러올 경우에는 많이 사용하는 느낌이지만, 실제로는 포함 관계를 더 많이 구현할 것 같다는 생각이 든다.
  • 이 코드를 작성하면서 가장 힘들었던 부분은 코드의 최적화보다도, 유지보수에 유리하게 만드는 것이 어려웠다고 생각이 든다. 사실 상속으로 여러 코드를 나눠볼까 라는 생각을 했지만, 유지보수를 현업에서 다양한 사람과 함께 하는 만큼 이해가 쉬운 코드를 우선순위 1번으로 생각하고 2번을 최적화로 생각하면서 코드를 진행했다.
  • 사실 어느쪽이 정답인지는 알 수 없으나, 가장 좋은 방법은 2가지를 다 챙기는 방법이라 생각한다.
  • 중요한 숙제는 프리셋을 구성하는 것이라 생각한다. 나 스스로 메인, 각 클래스, 메소드, 변수 선언에 일정한 규칙을 가질 수 있는 개발 뼈대를 만드는 것이 앞으로의 능력에 중요한 기준이 될 것이라 생각한다.
  • 다만, 그를 위해서는 깊은 수준의 CS와 JAVA 이해능력이 필요하다는 생각이 들었다. 추가적인 이론을 학습을 안할 수 는 없어보인다.

'Project > JAVA' 카테고리의 다른 글

[JAVA] SOLID 원칙  (1) 2025.01.23
[JAVA] 키오스크  (0) 2024.11.26

+ Recent posts