코드몽키
클린한 코드에 대하여 본문
포트폴리오 소개글에는 클린한 코드를 추구하는 개발자라고 적어두었다. 하지만 몇 달 전 면접에서 “당신이 생각하는 클린한 코드는 무엇인가요?” 라는 질문을 받았을 때, 나는 이렇게 대답했다.
“객체지향적 원칙을 준수하고, 네이밍을 의미 있고 간결하게 합니다.
복잡한 구조의 코드가 좋은 코드가 아니라, 다른 사람이 읽었을 때 명확히 이해할 수 있는 코드가 클린한 코드라고 생각합니다.”
그때 면접관님이 다시 물었다.
“그럼 실제 코드 중에 적용한 사례를 하나만 들어줄래요?”
그 순간, 나는 머리가 하얘졌고 결국 "SOLID... SOLID.." 무새가 되어버렸다.
며칠 전 또 다른 면접에서도 “이 코드를 리팩토링해보세요.”라는 요청을 받았지만, 리팩토링을 해보라는 면접관의 말에 머리에 스턴건을 맞고 내가 아는 지식의 1/10도 활용을 못한채 아쉬움 많은 리팩토링을 시연 했다. 탈락의 이유가 이것 뿐만 아니라 다른 이유들도 있었겠지만 그렇게 또 소중한 면접의 기회를 탈락하고 말았다...
집에 오고난 뒤 나는 분해서 잠이 오지 않았다. 결국 내린 답은 내가 알고있다고 "착각"하는 것이였다. 사실 제대로 알지 못하는 것이였다. 그때의 답답함을 계기로 내가 평소에 어떤 기준으로 코드를 작성하고 리팩토링 하는지, 그리고 그 과정에서 어떻게 SOLID 원칙을 의식적으로 적용하고 있는지를 구체적인 코드 사례를 통해 정리해 보고자 한다.
물론 SOLID 원칙은 절대적인 법칙이 아니다. 그것은 이제껏 사람들이 개발하면서 느낀 반복되는 실패와 어려움을 해결하기 위한 가이드라인 일 뿐이다. 이 원칙들을 적용하면 코드 품질이 좋아질 확률이 높지만 실무에서는 언제나 트레이드오프가 존재하며, 때로는 빠른 개발 속도나 유지보수 비용 절감을 위해 원칙을 일부 유연하게 적용해야 할 때도 있다.
결국 중요한 것은 왜 이원칙을 적용했는가 보다, 지금 상황에 왜 지금은 적용하지 않았는가를 스스로 설명할 수 있는 판단력인 것 같다. 아직 나에게 그 판단력이 완벽히 자리 잡은 건 아니지만, 몇가지 내가 "클린한" 코드로 실무에서 리팩토링하는 간단한 사례를 예를 들어 설명하고, 이를 몸으로 체득하여 누구에게나 설명하고 알려 줄 수 있는 코드 감각을 만들어가고자 한다.
기상 데이터 파일 생성 서비스를 예를 들어 보겠다
기상 데이터를 읽어와서 다양한 포맷(NetCDF, CSV, JSON)으로 저장해야 한다. NetCDF만 처리했지만, 이후 요구사항이 확장되어 CSV와 JSON도 지원해야 하는 상황이다.
class WeatherFileService {
fun export(data: List<WeatherData>, format: String) {
//데이터 가공
val processed = data.filter { it.isValid }
//파일 포맷별 처리
if (format == "netcdf") {
NetcdfWriter().write(processed)
} else if (format == "csv") {
CsvWriter().write(processed)
} else if (format == "json") {
JsonWriter().write(processed)
}
//로그 기록
println("${data.size} records in $format format")
}
}
언뜻 보면 평범한 코드처럼 보일 수 있겠지만 객체 지향 관점에서 바라본다면 이 코드는 몇가지 문제를 가지고 있다고 생각한다.
단일 책임 원칙(SRP) 위반 - 데이터 가공, 포맷별 파일 생성, 로그 기록이 모두 한 클래스에 섞여 있음
개방 폐쇄 원칙(OCP) 위반 - 파일 포맷 추가 시 export 메서드를 계속 수정해야 함
이를 리팩토링하여 아래와 같이 변경하였다.
class WeatherFileService(
private val writerFactory: FileWriterFactory,
private val logger: ExportLogger
) {
fun export(data: List<WeatherData>, format: String) {
val processed = process(data) // 데이터 가공 책임 분리
val writer = writerFactory.create(format) // 파일 생성 책임 분리
writer.write(processed)
logger.log(format, processed.size) // 로그 기록 책임 분리
}
private fun process(data: List<WeatherData>) =
data.filter { it.isValid }
}
인터페이스 또한 분리하여 적용 할 수 있다.
❌ 나쁜 예시
interface Writer {
fun writeCsv(data: List<WeatherData>)
fun writeNetcdf(data: List<WeatherData>)
fun writeJson(data: List<WeatherData>)
}
✅ 좋은 예시
interface FileWriter {
fun write(data: List<WeatherData>)
}
class NetcdfWriter : FileWriter {
override fun write(data: List<WeatherData>) {
println("Writing NetCDF file...")
}
}
class CsvWriter : FileWriter {
override fun write(data: List<WeatherData>) {
println("Writing CSV file...")
}
}
class JsonWriter : FileWriter {
override fun write(data: List<WeatherData>) {
println("Writing Json file...")
}
}
class FileWriterFactory {
fun create(format: String): FileWriter =
when (format.lowercase()) {
"netcdf" -> NetcdfWriter()
"csv" -> CsvWriter()
else -> throw IllegalArgumentException("Unsupported format: $format")
}
}
FileWriter 인터페이스는 write 메서드 하나만 제공하며, 각 구현체는 자신이 필요한 기능만 구현 한다. 불필요한 메서드를 강제하지 않으므로 인터페이스 분리 원칙(ISP) 를 준수한다.
팩토리 패턴을 활용하여 구체 클래스 생성 로직을 팩토리 내부로 캡슐화 하였다. 클라이언트는 단순히 FileWriter 인터페이스만 알면 되고, 새로웃 포맷이 추가할 때 클라이언트 코드를 수정할 필요가 없어진다.
이어서 아래의 코드 예시를 통하여 클래스가 구체적인 구현체(Concretion)가 아닌 추상화(Abstraction)에 의존해야 한다는 객체 지향 설계의 핵심 원칙을 적용하여 리팩토링한다는 걸 설명한다.
❌ 나쁜 예시 (구현 클래스 직접 의존)
class WeatherFileService {
private val writer = NetcdfWriter()
}
✅ 좋은 예시 (인터페이스에 의존)
@Service
class WeatherFileService(
private val writer: FileWriter
)
나쁜 예시에서는 WeatherFileService가 NetcdfWriter라는 구체적인 구현체에 직접 의존하고 있다.
이로 인해 두 클래스 간 결합도가 높아져 한쪽이 변경되면 다른 쪽에도 영향을 받게 된다. 또한 새로운 파일 형식을 지원하면 WeatherFileService의 내부 코드를 수정해야 하므로 유연성이 떨어지고, 이는 개방 폐쇄 원칙(OCP) 을 위반하는 결과로 이어진다.
단위 테스트 시에도 NetcdfWriter를 Mock 객체로 대체하기 어려워 실제 파일 생성 같은 부작용이 발생하며, 순수한 로직 검증이 힘들어진다.
좋은 예시는 의존성 역전 원칙(DIP) 을 적용해 WeatherFileService가 FileWriter라는 추상화(인터페이스) 에 의존하도록 설계한 것이다. 외부에서 구현체를 주입(DI)받기 때문에 느슨한 결합(Loose Coupling) 을 유지할 수 있다. 그 결과, 새로운 파일 형식을 지원할 때 기존 코드를 수정하지 않고 새로운 구현체만 추가하면 되므로 개방 폐쇄 원칙(OCP) 을 지키며 유연하게 확장할 수 있다.
또한 테스트 시 Mock 객체를 쉽게 주입할 수 있어 부작용 없이 핵심 로직만 검증할 수 있고, 런타임 설정만으로 동작을 유연하게 전환할 수 있는 장점이 있다.
물론 모든 경우에 인터페이스를 사용해야 하는 것은 아니다. 애플리케이션 내부에서만 사용되고, 변경 가능성이 거의 없는 클래스라면 굳이 인터페이스를 두지 않아도 된다. 예를 들어 단일 책임을 가진 유틸성 클래스나 단 한 곳에서만 사용하는 내부 객체라면 다음과 같이 구현체를 직접 사용하는 편이 단순하고 효율적이다
class TemperatureConverter {
fun toCelsius(fahrenheit: Double): Double = (fahrenheit - 32) * 5 / 9
}
class WeatherAnalyzer {
private val converter = TemperatureConverter()
fun analyze(fahrenheit: Double) {
val celsius = converter.toCelsius(fahrenheit)
}
}
이처럼 변화 가능성, 재사용성, 외부 주입 필요 여부를 기준으로 인터페이스 사용 여부를 판단하는 것이 현실적인 설계 방식이다.
즉, “모든 의존을 추상화하라”가 아니라 “변화 가능성이 높은 의존만 추상화하라”가 핵심이라고 생각된다.
이로써 간단히 실무에서 접할수 있는 일반적인 코드를 가지고 리팩토링을 하거나 디자인 패턴을 활용하면서 하는 한 나의 사례를 들어 보았다. 중요한 것은 SOLID 원칙 자체를 맹목적으로 적용하는 것이 아니라, 현재 상황과 요구사항을 고려하여 언제 적용하고, 언제 유연하게 접근할지를 스스로 판단할 수 있는 능력이다.
나는 앞으로도 이러한 관점에서 코드 작성과 리팩토링을 반복하며, 코드리뷰나 면접에서 자신 있게 사례를 설명하고, 명확히 나를 어필 할 수 있는 개발자로 성장하고 싶다.