시스템에 들어가는 모든 소프트웨어를 직접 개발하는 경우는 드물다. 때로는 패키지를 사고, 때로는 오픈 소스를 이용한다. 때로는 사내 다른 팀이 제공하는 컴포넌트를 사용한다. 어떤 식으로든 이 외부 코드를 우리 코드에 깔끔하게 통합해야만 한다.


외부 코드 사용하기

패키지 제공자나 프레임워크 제공자는 적용성을 최대한 넒히려 애쓴다. 더 많은 환경에서 돌아가야 더 많은 고객이 구매하니까. 반면, 사용자는 자신의 요구에 집중하는 인터페이스를 바란다.

한 예로 Map은 다양한 인터페이스를 제공한다. 하지만 그만큼 위험도 크다.

• clear() void – Map
• containsKey(Object key) boolean – Map
• containsValue(Object value) boolean – Map
• entrySet() Set – Map
• equals(Object o) boolean – Map
• get(Object key) Object – Map
• getClass() Class<? extends Object> – Object
• hashCode() int – Map
• isEmpty() boolean – Map
• keySet() Set – Map
• notify() void – Object
• notifyAll() void – Object
• put(Object key, Object value) Object – Map
• putAll(Map t) void – Map
• remove(Object key) Object – Map
• size() int – Map
• toString() String – Object
• values() Collection – Map
• wait() void – Object
• wait(long timeout) void – Object
• wait(long timeout, int nanos) void – Object

Map을 깔끔하게 사용한 코드다. Sensors 사용자는 제네릭스가 사용되었는지 여부에 신경 쓸 필요가 없다. 제네릭스의 사용 여부는 Sensors 안에서 결정한다.

public class Sensors {
 private Map sensors = new HashMap();
 public Sensor getById(String id) {
 return (Sensor) sensors.get(id);
 }
 // 이하 생략
}

경계 인터페이스인 Map을 Sensors 안으로  숨긴다. 따라서 Map 인터페이스가 변하더라도 나머지 프로그램에는 영향을 미치지 않는다. 제네릭스를 사용하든 하지 않든 더 이상 문제가 안 된다. Sensors 클래스 안에서 객체 유형을 관리하고 변환하기 때문이다.


경계 살피고 익히기

외부 코드를 사용하면 쉽고 빠르다. 하지만 이 코드의 테스트 및 안정성은 우리의 몫이다.

 우리쪽 코드를 작성해 외부 코드를 호출하는 대신 먼저 간단한 테스트 케이스를 작성해 외부 코드를 익히면 어떻까?

짐 뉴커크는 이를 학습 테스트라 부른다.

 학습 테스트는 프로그램에서 사용하려는 방식대로 외부 API를 호출한다. 통제된 환경에서 API를 제대로 이해하는지를 확인하는 셈이다. 학습 테스트는 API를 사용하려는 목적에 초점을 맞춘다.


Log4j 익히기

기초적인 "hello"를 출력하는 테스트 케이스를 한다.

@Test
public void testLogCreate() {
 Logger logger = Logger.getLogger("MyLogger");
 logger.info("hello");
}

테스트 케이스를 돌렸더니 Appender라는 오류가 나오고 이후에는 출력 스트림이 없다고 나온다. 그래서 코드를 수정한다.

@Test
public void testLogAddAppender() {
 Logger logger = Logger.getLogger("MyLogger");
 logger.removeAllAppenders();
 logger.addAppender(new ConsoleAppender(
 new PatternLayout("%p %t %m%n"),
 ConsoleAppender.SYSTEM_OUT));
 logger.info("hello");
}

위처럼 학습을 통해 지식을 얻는다.


학습 테스트는 공짜 이상이다.

학습 테스트는 이해도를 높여주면서 비용은 없는 실험이다.

 학습 테스트는 공짜 이상이다. 투자하는 노력보다 얻는 성과가 더 크다. 패키지가 예상대로 도는지 검증한다. 일단 통합한 이후라고 하더라도 패키지가 우리 코드와 호환되라라는 보장은 없다.


아직 존재하지 않는 코드를 사용하기

경계와 관련해  또 다른 유형은 아는 코드와 모르는 코드를 분리하는 경계다. 때로는 우리 지식이 경계를 너머 미치지 못하는 코드 영역도 있다. 때로는 알려고 해도 알 수가 없다. 때로는 더 이상 내다보지 않기로 결정한다.


깨끗한 경계

경계에서는 흥미로운 일이 많이 벌어진다. 변경이 대표적인 예다. 소프트웨어 설계가 우수하다면 변경하는데 많은 투자와 재작업이 필요하지 않다. 엄청난 시간과 노력과 재작업을 요구하지 않는다. 통제하지 못하는 코드를 사용할 때는 시간과 노력이 많이 필요하다.

 

' > Clean Code' 카테고리의 다른 글

[Clean Code] 4장 주석  (0) 2022.09.20
[Clean Code] 3장 함수  (0) 2022.09.19
[Clean Code] 2장 의미 있는 이름  (2) 2022.09.17
[Clean Code] 1장 깨끗한 코드  (1) 2022.09.16
[Clean Code] 7장 오류 처리  (0) 2022.09.14

오류 처리는 프로그램에 반드시 필요한 요소 중 하나일 뿐이다. 잘못될 가능성은 늘 존재한다.

깨끗한 코드와 오류 처리는 확실히 연관성이 있다. 상당수 코드 기반은 전적으로 오류 처리 코드에 좌우된다.


오류 코드보다 예외를 사용하라

얼마 전까지만 해도 예외를 지원하지 않는 프로그래밍 언어가 많았다.

public class DeviceController {
 ...
 public void sendShutDown() {
 DeviceHandle handle = getHandle(DEV1);
 // 디바이스 상태를 점검한다.
 if (handle != DeviceHandle.INVALID) {
 // 레코드 필드에 디바이스 상태를 저장한다.
 retrieveDeviceRecord(handle);
 // 디바이스가 일시정지 상태가 아니라면 종료한다.
 if (record.getStatus() != DEVICE_SUSPENDED) {
 pauseDevice(handle);
 clearDeviceWorkQueue(handle);
closeDevice(handle);
 } else {
 logger.log("Device suspended. Unable to shut down");
 }
 } else {
 logger.log("Invalid handle for: " + DEV1.toString());
 }
 }
 ...
}

위와 같은 방법을 사용하면 호출자 코드가 복잡해진다. 함수를 호출한 즉시 오류를 확인해야 하기 때문이다.

위와 같은 단계는 잊어버리기 쉽다. 그래서 오류가 발생하면 예외를 던지는 편이 낫다.

public class DeviceController {
 ...
 public void sendShutDown() {
 try {
 tryToShutDown();
 } catch (DeviceShutDownError e) {
 logger.log(e);
 }
 }
 private void tryToShutDown() throws DeviceShutDownError {
 DeviceHandle handle = getHandle(DEV1);
 DeviceRecord record = retrieveDeviceRecord(handle);
 pauseDevice(handle);
 clearDeviceWorkQueue(handle);
 closeDevice(handle);
 }
 private DeviceHandle getHandle(DeviceID id) {
 ...
throw new DeviceShutDownError("Invalid handle for: " + id.toString());
 ...
 }
 ...
}

코드의 품질이 나아졌다. 알고리즘과 오류 처리 부분을 분리했기 때문이다. 이제는 각 개념을 독립적으로 살펴 보고 이해할 수 있다.


Try-Catch-Finally 문부터 작성하다.

어떤 면에서 try 블록은 트랜잭션과 비슷하다. try 블록에서 무슨 일이 생기든지 catch 블록은 프로그램 상태를 일관성 있게 유지해야 한다. 그러므로 예외가 발생할 코드를 짤 때는 rry-catch-finally 문으로 시작하는 편이 낫다. 그러면 try 블록에서 무슨 일이 생기든지 호출자가 기대하는 상태를 정의하기 쉬워진다.

@Test(expected = StorageException.class)
public void retrieveSectionShouldThrowOnInvalidFileName() {
 sectionStore.retrieveSection("invalid - file");
}
public List<RecordedGrip> retrieveSection(String sectionName) {
 // 실제로 구현할 때까지 비어 있는 더미를 반환한다.
 return new ArrayList<RecordedGrip>();
}

아래 코드는 예외를 던진다.

public List<RecordedGrip> retrieveSection(String sectionName) {
 try {
 FileInputStream stream = new FileInputStream(sectionName)
 } catch (Exception e) {
 throw new StorageException("retrieval error", e);
 }
 return new ArrayList<RecordedGrip>();
}
public List<RecordedGrip> retrieveSection(String sectionName) {
 try {
 FileInputStream stream = new FileInputStream(sectionName);
 stream.close();
 } catch (FileNotFoundException e) {
 throw new StorageException("retrieval error", e);
 }
 return new ArrayList<RecordedGrip>();
}

미확인 예외를 사용하라

여러 해 동안 자바 프로그래머들은 확인된 예외의 장단점을 놓고 논쟁을 벌여왔다. 메서드를 선언할 때는 ㅔㅁ서드가 반환할 예외를 모두 열거했다. 게다가 메서드가 반환하는 예외는 메서드 유형의 일부였다. 코드가 메서드를 사용하는 방식이 메서드 선언과 일치하지 않으면 아예 컴파일도 못했다.

확인된 예외는 OCP(open closed principle)을 위반한다. 메서드에서 확인된 예외를 던졌는데 catch 블록이 세단계 위에 있다면 그 사이 메서드 모두가 선언부에 해당 예외를 정의해야 한다. 즉, 하위 단계에서 코드를 변경하면 상위 단계 메서드 선언부를 전부 고쳐야 한다는 말이다.


예외에 의미를 제공하라

예외를 던질 때는 전후 상황을 충분히 덧붙인다. 그러면 오류가 발생한 원인과 위치를 찾기가 쉬워진다. 자바는 모든 예외에 호출 스택을 제공한다. 하지만 실패한 코드의 의도를 파악하려면 호출 스택만으로 부족하다.

오류 메시지에 정보를 담아 예외와 함께 던진다. 실패한 연산 이름과 실패 유형도 언급한다. 애플리케이션이 로깅 기능을 사용한다면 catch블록에서 오류를 기록하도록 충분한 정보를 넘겨준다.


호출자를 고려해 예외 클래스를 정의하라

오류를 분류하는 방법은 수없이 많다. 오류가 발생한 위치로 분류가 가능하다. 예를 들어, 오류가 발생한 컴포넌트로 분류한다. 아니면 유형으로도 분류가 가능하다.

ACMEPort port = new ACMEPort(12);
try {
 port.open();
} catch (DeviceResponseException e) {
 reportPortError(e);
 logger.log("Device response exception", e);
} catch (ATM1212UnlockedException e) {
 reportPortError(e);
 logger.log("Unlock exception", e);
} catch (GMXError e) {
reportPortError(e);
 logger.log("Device response exception");
} finally {
 ...
}

위는 외부 라이브러리가 던질 예외를 모두 잡은것으로 형편없이 분류한 사례다.

LocalPort port = new LocalPort(12);
try {
 port.open();
} catch (PortDeviceFailure e) {
 reportError(e);
 logger.log(e.getMessage(), e);
} finally {
 ...
}

호출하는 라이브러리 API를 감싸면서 예외 유형 하나를 반환하면 된다.

감싸기 기법을 사용하면 특정 업체가 API를 설계한 방식에 발목 잡히지 않는다. 프로그램이 사용하기 편리한 API를 정의하면 그만이다.

흔히 예외 클래스가 하나만 있어도 충분한 코드가 많다. 예외 클래스에 포함된 정보로 오류를 구분해도 괜찮은 경우가 그렇다. 한 예외는 잡아내고 다른 예외는 무시해도 괜찮은 경우라면 여러 예외 클래스를 사용한다.


정상 흐름을 정의하라

위의 흐름대로 예외를 정의하다 보면 오류 감지가 프로그램 언저리로 밀려난다. 외부 API를 감싸 독자적인 예외를 던지고, 코드 위에 처리기를 정의해 중단된 계산을 처리한다. 대개는 멋진 처리 방식이지만, 때로는 중단이 적합하지 않은 때도 있다.


null을 반환하지 마라

오류 처리를 논하는 장이라면 우리가 흔히 저지르는 바람에 오류를 유발하는 행위도 언급해야 한다고 생각한다. 그 중 첫째가 null을 반환하는 습관이다.

public void registerItem(Item item) {
 if (item != null) {
 ItemRegistry registry = peristentStore.getItemRegistry();
 if (registry != null) {
 Item existing = registry.getItem(item.getID());
 if (existing.getBillingPeriod().hasRetailOwner()) {
 existing.register(item);
 }
 }
 }
}

위 코드는 두번째 행에서 null확인이 누락된다.


null을 전달하지 마라

정상적인 인수로 null을 기대하는 API가 아니라면 메서드로 null을 전달하는 더 나쁘다. 정상적인 인수로 null을 기대하는 API가 아니라면 메서드로 null을 전달하는 코드는 최대한 피한다.

public class MetricsCalculator
{
 public double xProjection(Point p1, Point p2) {
return (p2.x – p1.x) * 1.5;
 }
 ...
}

인수로 누군가 null을 전달하면??

당연히 NullPointException이 발생한다.

대다수 프로그래밍 언어는 호출자가 실수로 넘기는 null을 적절히 처리하는 방법이 없다. 그렇다면 애초에 null을 넘기지 못하도록 금지하는 정책이 합리적이다.


결론

깨끗한 코드는 읽기도 좋아야 하지만 안정성도 높아야 한다. 이 둘은 상충하는 목표가 아니다. 오류 처리를 프로그램 논리와 분리해 독자적인 사안으로 고려하면 튼튼하고 깨끗한 코드를 작성할 수 있다. 오류 처리를 프로그램 논리와 분리하면 독립적인 추론이 가능해지며 코드 유지보수성도 크게 높아진다.

' > Clean Code' 카테고리의 다른 글

[Clean Code] 4장 주석  (0) 2022.09.20
[Clean Code] 3장 함수  (0) 2022.09.19
[Clean Code] 2장 의미 있는 이름  (2) 2022.09.17
[Clean Code] 1장 깨끗한 코드  (1) 2022.09.16
[Clean Code] 8장 경계  (2) 2022.09.15

+ Recent posts