SOLID 원칙은 객체 지향 설계(OOP)에서 지켜야 할 5가지 소프트웨어 개발 원칙(SRP, OCP, LSP, ISP, DIP)을 뜻한다. 객체 지향의 4가지 특징(추상화, 상속, 다형성, 캡슐화)과 더불어 기술 면접의 단골 질문이기도 하다.
앞으로 마주하게 될 여러 디자인 패턴(Design Pattern)들 역시 이 SOLID 원칙에 입각해서 만들어졌기 때문에, 표준화 작업부터 아키텍처 설계에 이르기까지 개발의 근간이 되는 중요한 개념이다.
본래 좋은 소프트웨어란 변화에 잘 대응하는 것을 의미한다. 갑작스럽게 기획에 없는 추가 요청이 들어오더라도, 시스템에 예상치 못한 변경 사항이 발생하더라도 유연하게 대처할 수 있어야 한다.
SOLID 원칙을 적용하면 코드를 확장하고 유지 보수하기 쉬워지며, 불필요한 복잡성을 제거해 리팩토링 시간을 줄이고 프로젝트 개발의 생산성을 높일 수 있다.
참고: SOLID 적용에 대한 Q\&A
Q. SOLID 원칙을 적용하는 순서가 있나요?
A. 없다. SOLID라는 단어는 단순히 철자법상 기억하기 쉽게 배열된 것일 뿐, 적용에 정해진 순서는 없다.Q. 프로젝트에 반드시 5가지 원칙을 모두 적용해야 하나요?
A. 아니다. 각 원칙은 특정 문제를 해결하기 위한 지침일 뿐이다. 코드에 해당 문제가 없다면 무리해서 원칙을 욱여넣을 필요는 없다. 또한 특정 언어나 프레임워크에 국한되지 않으므로, OOP를 지원하는 언어라면 어디든 자유롭게 적용할 수 있다.
"클래스(객체)는 단 하나의 책임만 가져야 한다."
여기서 '책임'이란 **'하나의 기능 담당'**을 뜻한다. 즉, 하나의 클래스는 하나의 기능에만 집중하도록 여러 개로 쪼개어 설계하라는 의미다.
만약 한 클래스에 여러 기능이 섞여 있다면, A 기능을 수정했을 때 연쇄적으로 B와 C까지 수정해야 하는 책임의 순환이 발생한다. SRP를 따르면 이러한 연쇄 작용을 방지하고 프로그램의 유지 보수성을 크게 높일 수 있다.
Tip
청소기 클래스는 '청소'만 잘하면 된다. 화분에 물을 주고 드라이까지 하는 다재다능한 청소기를 만들면 좋아 보일 수 있지만, 청소기가 고장 나면 다른 기능까지 전부 사용하지 못하게 된다.
쉽게 말해, 하나의 클래스로 너무 많은 일을 하려 하지 말고 딱 한 가지 책임만 수행하게 만들어야 한다.
"확장에는 열려있어야 하고, 수정에는 닫혀있어야 한다."
새로운 기능 추가 요청이 오면 기존 객체를 직접 수정하는 일은 제한(수정에 닫힘)하고, 코드를 유연하게 추가하여 애플리케이션의 기능을 확장(확장에 열림)할 수 있게 설계해야 한다.
어렵게 생각할 필요 없이, 자바의 **추상 클래스와 상속(인터페이스)**을 활용한 관계 구축을 생각하면 된다. 다형성과 확장을 가능케 하는 객체 지향의 장점을 극대화하는 가장 기본적인 설계 원칙이다.
"서브 타입은 언제나 기반(부모) 타입으로 교체할 수 있어야 한다."
다형성을 안전하게 이용하기 위한 원칙이다. 상위 클래스 타입으로 객체를 선언한 뒤 하위 클래스의 인스턴스를 할당(업캐스팅)하더라도, 부모의 메서드를 사용할 때 원래 의도대로 문제없이 작동해야 한다.
따라서 부모 메서드를 오버라이딩(Overriding)할 때는 신중해야 한다. 부모 클래스가 기대하는 선행 조건이나 동작 규칙을 자식 클래스가 깨버리면 예상치 못한 에러가 발생할 수 있기 때문이다.
Tip
자바의Collection인터페이스가 좋은 예다.Collection타입 변수에LinkedList를 할당했다가 중간에 전혀 다른 자료형인HashSet으로 바꾸더라도,add()메서드는 각자의 자료형에 맞게 원래 의도대로 보장되어 작동한다.
public void myData() {
// Collection 인터페이스 타입으로 변수 선언
Collection data = new LinkedList();
// 중간에 전혀 다른 자료형 클래스를 할당해도 호환됨 (LSP)
data = new HashSet();
modify(data);
}
public void modify(Collection data){
// 인터페이스 구현 구조가 잘 잡혀있어 의도대로 작동됨
data.add(1);
}
"인터페이스를 클라이언트의 사용 목적에 맞게 잘게 분리해야 한다."
SRP(단일 책임 원칙)가 클래스의 단일 책임을 강조한다면, ISP는 인터페이스의 단일 책임을 강조한다. 클라이언트를 기준으로 분리하여, 사용자가 실제로 필요로 하는 인터페이스만 제공하는 것이 목표다.
단, 주의할 점은 한 번 인터페이스를 분리해 두었다면, 웬만해서는 나중에 수정 사항이 생겼다고 또 인터페이스를 쪼개거나 변경하는 일은 피해야 한다. (인터페이스는 변하지 않아야 하는 정책의 개념)
Tip
클래스와 달리 인터페이스는 다중 구현(implements)이 제약 없이 가능하다. 따라서 분리할 수 있는 만큼 분리하여 각 클래스의 용도에 맞게 조합해서 쓰는 것이 좋다.
"구현 클래스에 의존하지 말고, 추상화(인터페이스)에 의존하라."
어떤 클래스를 참조해야 하는 상황이 생긴다면, 대상의 구체적인 구현체(Class)를 직접 참조하지 말고 그 대상의 상위 요소인 추상 클래스나 인터페이스를 참조해야 한다는 원칙이다.
의존 관계를 맺을 때는 변화하기 쉬운 것보다 변화가 거의 없는 뼈대에 의존해야 한다. 이 원칙의 궁극적인 지향점은 각 클래스 간의 결합도(Coupling)를 낮추는 것이다.
마무리 요약
S.O.L.I.D의 원칙들은 결국 자바의 추상화, 상속, 인터페이스, 다형성 개념을 명확히 재정립한 것이다. 이 5가지 원칙은 서로 독립된 것이 아니라, 서로가 서로를 이용하고 포함하며 유기적으로 연관되어 작동한다.\
자바 프로그래밍에서 데이터를 안전하게 보호하는 방법인 캡슐화(Getter/Setter)와, 프로그램 실행 중 발생할 수 있는 오류를 제어하는 예외 처리(Exception)에 대해 정리한다.
객체 지향 프로그래밍에서는 객체의 데이터(필드)를 외부에서 함부로 변경하지 못하도록 보호해야 한다. 이를 **캡슐화(Encapsulation)**라고 하며, 자바에서는 접근 제한자와 Getter/Setter 메서드를 통해 이를 구현한다.
private (접근 제한자): 클래스 내부에서만 접근할 수 있다. 외부에서 직접 수정이 불가능하여 데이터를 안전하게 보호한다.public: 어디서든 자유롭게 접근할 수 있다.Getter: private 필드의 값을 외부에서 읽을 수 있게 반환해 주는 메서드이다.Setter: private 필드의 값을 외부에서 안전하게 변경할 수 있게 해주는 메서드이다. 필요에 따라 값의 유효성 검사 로직을 추가할 수 있다.// getter, setter, private, public 개념을 설명하기 위한 클래스
class Person {
// private: 클래스 내부에서만 접근 가능. (데이터 보호)
private String name;
private int age;
// Getter: private 필드의 값을 반환하는 메서드 (public)
public String getName() {
return name;
}
// Setter: private 필드의 값을 설정하는 메서드 (public)
public void setName(String name) {
this.name = name;
}
public int getAge() {
return age;
}
// Setter 내부에 유효성 검사를 추가한 예시
public void setAge(int age) {
if (age > 0) {
this.age = age;
} else {
System.out.println("나이는 0보다 커야 합니다.");
}
}
}
class Main {
public static void main(String[] args) {
Person person = new Person();
// Setter를 사용하여 값 설정 (public 메서드이므로 접근 가능)
person.setName("홍길동");
person.setAge(25);
// private 필드에는 직접 접근 불가 (컴파일 에러 발생)
// person.name = "김철수"; // Error: name has private access in Person
// Getter를 사용하여 값 읽기 (public 메서드이므로 접근 가능)
System.out.println("이름: " + person.getName());
System.out.println("나이: " + person.getAge());
}
}
컴파일러가 미리 알 수 없는, 프로그램 실행 중 발생하는 에러를 자바에서는 '예외(Exception)'라고 부른다. 응용프로그램에서 이러한 예외를 적절히 처리하지 않으면 프로그램은 강제 종료된다.
try-catch-finally 구조예외를 안전하게 처리하기 위해 사용하는 기본 구문이다. finally 블록은 필요에 따라 생략할 수 있다.
try {
// 예외가 발생할 가능성이 있는 실행문 (try 블록)
} catch (처리할_예외_타입 선언) {
// 예외가 발생했을 때 실행할 예외 처리문 (catch 블록)
} finally {
// 예외 발생 여부와 상관없이 무조건 실행되는 문장 (finally 블록)
}
1) 예외가 발생하지 않은 정상적인 경우
try 블록 안의 실행문이 모두 정상 처리된다.catch 블록은 건너뛴다.finally 블록이 실행된다.2) 예외가 발생한 경우
try 블록을 실행하던 중 예외가 터지면 남은 코드는 중단된다.catch 블록으로 이동하여 예외 처리문을 실행한다.finally 블록이 실행된다.| 예외(Exception) 종류 | 발생하는 경우 |
|---|---|
ArithmeticException | 정수를 0으로 나눌 때 발생한다. |
NullPointerException | 객체가 없는 null 상태인 레퍼런스를 참조하여 사용할 때 발생한다. |
ClassCastException | 변환할 수 없는 타입으로 객체를 강제 변환(캐스팅)할 때 발생한다. |
OutOfMemoryError | 시스템의 메모리가 부족한 경우 발생한다. |
ArrayIndexOutOfBoundsException | 배열에 할당된 범위를 벗어난 인덱스에 접근할 때 발생한다. |
IllegalArgumentException | 메서드에 잘못된 인자(파라미터)를 전달했을 때 발생한다. |
IOException | 파일 읽기/쓰기 등 입출력 동작이 실패하거나 인터럽트 되었을 때 발생한다. |
NumberFormatException | 문자열이 나타내는 숫자와 일치하지 않는 타입의 숫자로 변환을 시도할 때 발생한다. (예: "abc"를 정수로 변환 시도) |
자바에서 자주 사용되는 배열과 ArrayList의 차이점, 필수 문자열 함수, 그리고 데이터의 집합을 다루는 HashSet과 HashMap의 개념을 정리한다.
배열과 ArrayList는 데이터를 순차적으로 저장한다는 공통점이 있지만, 길이의 가변성에서 큰 차이를 보인다.
import java.util.ArrayList;
class Main {
public static void main(String[] args) {
// 1. 배열(Array) 버전
// 크기를 2로 고정하여 선언
String[] names = new String[2];
names[0] = "철수";
names[1] = "영희";
System.out.println("== 배열 버전 출력 ==");
for (int i = 0; i < names.length; i++) {
System.out.println(names[i]);
}
// 2. ArrayList 버전
// 크기를 지정하지 않고 동적으로 추가
ArrayList<String> nameList = new ArrayList<>();
nameList.add("철수");
nameList.add("영희");
System.out.println("== ArrayList 버전 출력 ==");
for (int i = 0; i < nameList.size(); i++) {
System.out.println(nameList.get(i));
}
}
}
문자열을 조작하고 검사할 때 필수적으로 사용되는 내장 메서드들이다.
length(): 문자열의 길이를 반환한다.charAt(int index): 주어진 인덱스에 위치한 문자를 반환한다. (인덱스는 0부터 시작)substring(int beginIndex, int endIndex): 문자열의 일부를 잘라서 반환한다. 시작 인덱스부터 끝 인덱스 직전까지 추출한다.toUpperCase() / toLowerCase(): 문자열을 대문자 또는 소문자로 일괄 변환한다.trim(): 문자열의 앞뒤에 있는 공백을 제거한다. (문자열 중간의 공백은 제거하지 않음)indexOf(str): 문자열에서 특정 문자가 시작되는 첫 번째 인덱스를 반환한다.split(str): 지정한 구분자로 문자열을 나누어 배열 형태로 반환한다. 반환 값을 배열 변수에 담아야 한다.startsWith(str): 문자열이 특정 문자로 시작하는지 판별하여 boolean 값을 반환한다.endsWith(str): 문자열이 특정 문자로 끝나는지 판별하여 boolean 값을 반환한다.equals(str): 두 문자열의 값을 비교한다. (자바에서 문자열 비교는 == 대신 반드시 equals를 사용해야 함)contains(str): 특정 문자열이 포함되어 있는지 확인한다.replace(CharSequence target, CharSequence replacement): 첫 번째 인자로 문자열이 들어가며, 일치하는 모든 문자열을 바꾼다.replaceAll(String regex, String replacement): 첫 번째 인자로 정규표현식(Regex)이 들어간다. 정규식 규칙에 맞는 모든 패턴을 바꾼다.replaceFirst(String regex, String replacement): 조건에 맞는 첫 번째 문자만 변경한다.HashSet은 순서를 보장하지 않고, 데이터의 중복을 허용하지 않는 자료구조이다.
add(값): 데이터를 추가한다.contains(값): 특정 값이 포함되어 있는지 확인한다. (true/false)size(): Set에 들어있는 데이터의 개수를 반환한다.remove(값): 특정 값을 삭제한다.clear(): 내부의 모든 데이터를 삭제한다.import java.util.HashSet;
class Main {
public static void main(String[] args) {
HashSet<String> set = new HashSet<>();
set.add("철수");
set.add("영희");
set.add("철수"); // 중복된 값이므로 추가되지 않음
// 출력 결과에는 "철수", "영희"만 존재하며 순서는 무작위일 수 있음
System.out.println(set);
}
}
데이터를 다룰 때 List와 Map은 값을 저장하고 꺼내는 방식에서 뚜렷한 장단점을 가진다.
Key와 Value의 쌍으로 데이터를 저장한다.
put(Key, Value): 데이터를 추가한다.get(Key): Key에 해당하는 Value를 가져온다.containsKey(Key): 특정 Key가 존재하는지 확인한다.size(), remove(Key), clear()add()만 하면 자동으로 인덱스 번호가 부여되어 편하다.remove(0) 등) 기존 데이터들의 인덱스 번호가 밀리거나 당겨져서 원하는 값을 정확히 찾아내기 어려워진다.import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
class Main {
public static void main(String[] args) {
System.out.println("== ArrayList ==");
List<Integer> ages = new ArrayList<>();
// 데이터를 넣을 때는 편하다 (자동 라벨링)
ages.add(10); // 0번: 철수
ages.add(20); // 1번: 영희
ages.add(30); // 2번: 영수
ages.add(1, 40); // 1번 자리에 철희 삽입 (영희, 영수는 뒤로 밀림)
ages.remove(0); // 0번(철수) 삭제
// 데이터를 가져올 때는 불편하다 (인덱스가 변동됨)
// 철희 나이는 처음에 1번 인덱스에 들어갔으나, 0번이 삭제되면서 최종적으로 0번 인덱스가 됨
System.out.println("철희 나이 : " + ages.get(0));
System.out.println("== Map ==");
Map<String, Integer> agesMap = new HashMap<>();
// 데이터를 넣을 때는 불편하다 (수동 라벨링)
agesMap.put("철수", 10);
agesMap.put("영희", 20);
agesMap.put("영수", 30);
agesMap.put("철희", 40);
agesMap.remove("철수");
// 데이터를 가져올 때는 편하다 (Key는 변하지 않음)
System.out.println("철희 나이 : " + agesMap.get("철희"));
}
}