아이템 51 - 메서드 시그니처를 신중히 설계하라
메서드 시그니처란?
메서드 시그니처란 메서드의 이름과 매개변수의 순서, 타입, 개수를 의미합니다. 재밌는 건 메서드의 리턴 타입과 예외 처리(throws Exception)하는 부분은 메서드 시그니처가 아닙니다.
메서드의 이름과 매개변수의 정보만 강조하는 이유는 오버로딩때문이라네요 :)
메서드 시그니처를 설계하는 방법
메서드 이름을 신중히 짓기
항상 표준 명명 규칙을 따릅니다 (이펙티브 자바 아이템 68 참고)
표준 명명 규칙을 따르기 힘들다면?
- 자바 라이브러리의 API를 참고합니다
- 개발자 커뮤니티에서 널리 받아들여지는 이름을 사용합니다
편의 메서드를 너무 많이 만들지 말기
편의 메서드란?
편의 메서드(convenience method)란 말 그대로 편의를 위한 메서드입니다.
예를 들면, 헬퍼 클래스인 Collections
안에 있는 모든 메서드(swap
, min
, max
등등…)를 말합니다. JPA를 사용하신다면 1:N 관계 중 N쪽에 요소를 추가할 때 직접 만들어서 사용하는 add()
메서드도 편의 메서드입니다.
왜 편의 메서드를 만들까요?
편의 메서드가 없어도 기능 상 문제가 없어 그대로 사용할 수 있습니다. 그러나 편의 메서드를 사용하면 메서드에 이름을 줘서 좀 더 명확하게 무슨 일을 하는 지 말해줄 수 있으며 기능 단위로 묶을 수 있고 재사용할 수 있습니다.
왜 편의 메서드를 많이 만들면 안될까요?
- 메서드가 너무 많은 클래스는 문서화하고, 테스트하고 유지보수하고 사용하기 어렵습니다
- 클래스나 인터페이스는 자신의 기능을 수행하는 메서드를 제공해야 합니다
자주 쓰일 경우에만 만들고 확신이 서지 않으면 만들지 않는 게 낫습니다
매개변수 개수는 적게 유지하기
매개변수 갯수가 4개를 넘어가면 매개변수를 기억하기 쉽지 않다고 합니다. 4개 이하를 유지합시다.
만약 같은 타입의 매개변수가 여러 개라면 더 고민해봐야 합니다. 왜냐하면 실수로 순서를 바꿔 입력해도 그대로 컴파일되고 실행되기 때문입니다.
매개변수 개수를 줄여주는 기술 세 가지
1 - 여러 메서드로 쪼개기
매개변수가 너무 많다면 원래 매개변수의 부분 집합을 받아 메서드를 쪼갤 수 있습니다.
예를 들어, 어떤 리스트에서 주어진 원소를 찾아야 하는데 전체 리스트가 아니라 지정된 범위의 부분 리스트에서 주어진 원소를 찾아야 하는 요구사항이 들어왔다고 해볼게요. 이 기능을 하나의 메서드로 구현하려면 부분 리스트의 시작, 부분 리스트의 끝, 찾을 원소 까지 총 3개의 매개변수가 필요합니다.
findElementAtSubList(int fromIndexOfSubList, int toIndexOfSubList, Object element);
여기서 여러 메서드로 쪼갠다면 아래와 같은 방식으로 쪼갤 수 있습니다.
List<E> subList(int fromIndex, int toIndex);
int indexOf(Object o);
요구사항을 구현할 때는 subList()
로 List
를 구한 후, indexOf()
로 원소를 찾으면 됩니다.
여기서 눈여겨볼 것은 단순히 매개변수의 개수가 많다고 해서 여러 메서드로 쪼개는 것이 아니라는 겁니다.
주어진 요구사항을 다시 생각해볼게요. 지정된 범위의 부분 리스트에서 주어진 원소를 찾아야 하는 요구사항입니다. 이 요구사항에는 기능을 두 가지로 분리할 수 있습니다. 지정된 범위의 부분 리스트를 구하는 기능과 주어진 원소를 찾는 기능입니다. 이 두 기능을 생각해보면 공통점이 없습니다.
공통점이 없는데 하나의 메서드로 쓰이는 게 맞을까요? 아니죠. 분리되어야 합니다 이렇게 분리해서 기능으로 만든다면 다른 곳에서도 쉽게 조합해서 사용할 수 있습니다.
‘공통점이 없는 기능이 잘 분리되었다’를 전문적인 말로 ‘직교성이 높다’라고 합니다. (저도 처음들었습니다ㅎㅎ) 아키텍처에도 직교성을 대입해본다면 마이크로 서비스 아키텍처는 직교성이 높고, 모놀리식 아키텍처는 직교성이 낮다고 할 수 있습니다.
기능을 쪼개다보면 자연스럽게 중복이 줄고 결합성이 낮아집니다. 코드를 수정하고 테스트하기 쉬워지는 거죠. 그렇다고 무한정 작게 나누는 게 좋은 건 아닙니다. API 사용자의 눈높이에 맞게, API가 다루는 개념의 추상화 수준에 맞게 조절해야 합니다.
기능을 잘 쪼갠(직교성이 높게 개발한) 예시가 List 인터페이스입니다. List 인터페이스를 보면 말 그대로 subList()
는 부분 리스트를 반환하고 indexOf()
메서드는 주어진 원소의 인덱스를 알려줍니다. 별개로 제공된 두 메서드를 조합하면 우리가 원하던 지정된 범위의 부분 리스트에서 인덱스를 찾는 기능을 완성시킬 수 있는 거죠.
2 - 매개변수 여러 개를 묶는 도우미 클래스를 만들기
매개변수가 많다면 매개변수를 묶어줄 수 있는 클래스를 만들어서 하나의 객체로 전달할 수 있습니다.
예를 들어, 카드 게임인 블랙잭을 구현한다고 해볼게요. 카드 게임에서 각 게임자에게 카드를 나누는 행위(dealing)를 구현한다면 게임자에게 카드를 나눠야 하니 게임자의 이름과 카드가 필요합니다. rank는 카드의 숫자이며 suit은 카드의 무늬입니다.
dealing(String gamerName, String rank, String suit)
rank(카드의 숫자), suit(카드의 무늬)는 항상 같이 다니게 됩니다. 도메인을 생각했을 때 게임자에게 카드를 주지 카드의 숫자를 따로 주진 않으니까요.
dealing(String gamerName, Card card)
class Blackjack {
// 도우미 클래스 (정적 멤버 클래스)
static class Card {
private String rank;
private String suit;
}
public void dealing(gamerName, card);
}
이처럼 rank와 suit을 묶는 도우미 클래스(정적 멤버 클래스)를 만들면 하나의 매개변수로 주고 받을 수 있습니다.
예시에 개인적인 견해를 추가하자면 Card는 다른 곳에서도 쓰이고 카드 자체의 역할(rank와 suit의 우선순위 등)이 있기 때문에 매개변수를 위한 도우미 클래스가 아니라 클래스를 따로 만드는 게 맞다고 생각합니다. rank나 suit도 확장과 유연함을 고려해(10을 넘으면 J, Q, K로 표현되는 등) String
보다 Enum
으로 만든다면 더 나은 설계가 될 것 같네요 :)
3 - 빌더 패턴을 적용한 객체를 메서드 호출에 응용하기
도우미 클래스에 빌더 패턴을 적용한 것이라고 생각하시면 됩니다.
먼저 모든 매개변수를 하나로 추상화한 객체를 정의합니다. 그리고 그 객체에 빌더 패턴을 적용합니다. 클라이언트에서 해당 객체의 setter를 호출해 필요한 값을 설정합니다. 마지막으로 validate()
를 통해 필드 유효성 검사를 합니다. 객체를 넘겨 계산을 수행합니다. 매개 변수가 많으면서 그 중 일부는 생략해도 좋을 때 도움이 됩니다.
매개변수의 타입으로는 클래스보다 인터페이스가 더 낫다
매개변수 타입으로 인터페이스를 사용하면 훨씬 더 나은 유연함을 제공합니다. 예를 들어, HashMap이
아니라 인터페이스인 Map을 사용하면 TreeMap
, ConcurrentHashMap
, TreeMap
등 어떤 Map
구현체라도 인수로 건넬 수 있습니다.
만약 HashMap 클래스를 매개변수 타입으로 사용한다면 클라이언트에게 특정 구현체만 사용하도록 제한하는 꼴이 됩니다. 혹여나 입력 형태가 HashMap이 아닌 다른 형태로 존재한다면 HashMap으로 옮겨 담아야 하니 그만큼의 복사 비용을 치뤄야 합니다.
번외 (최범균님의 프로그래밍 왕초식 : 파라미터)
매개변수에 대한 이야기가 많이 나와서 넣어봤습니다ㅎㅎ 이 단락은 최범균님의 프로그래밍 왕초식 : 파라미터를 정리한 것으로 직접 보시면 더 이해가 잘 가실 거예요! 출처는 아래 링크에 있습니다 :)
Data
라는 매개변수가 otherDao.update()
, anyDao.insert()
등 여러 메서드에 쓰이고 있습니다.
public void update(Data data) {
Some s = getSome(data);
data.setKey(s.getKey());
int ret = otherDao.update(data);
if (ret == 1) {
data.setReg(new Date());
anyDao.insert(data);
}
}
여기서 만약에 anyDao.insert()
에 ip
라는 추가 값이 필요하다면 어떻게 해야될까요? Data
타입에 필드를 추가해야겠죠?
public class Data {
private Long id;
...
private String ip; // 추가
}
public void update(Data data) {
Some s = getSome(data);
data.setKey(s.getKey());
int ret = otherDao.update(data);
if (ret == 1) {
data.setReg(new Date());
data.setIp(s.getIp()); // 추가 값을 위한 setting
anyDao.insert(data);
}
}
이 구조는 한 매개변수 타입을 여러 곳에서 공유하는 구조입니다. Data
라는 타입으로 여러 객체(otherDao
, anyDao
)의 메서드에서 사용하고 있습니다.
초기에는 코드를 덜 만들고 변경해야할 곳도 적어서 좋다고 생각할 수 있습니다. 그러나 길게 생각했을 땐 좋은 방법이 아닙니다.
단점
- 메서드에서 Data로만 매개변수를 받아 어떤 값을 사용하는 지 알 수 없습니다.
- Data 타입으로만 매개변수를 받기 때문에 데이터 흐름을 추적하기 어렵습니다.
- 머리로 메서드와 필드 간 매핑을 기억해야 합니다.
이런 이유로 귀찮아도 메서드에 맞는 매개변수를 사용하는 것이 좋습니다. 각 메서드에서 어떤 값을 사용하는 지 알 수 있고, 명시적인 데이터 변환으로 데이터 흐름 추적이 쉬우며 머리로 메서드와 필드 간 매핑 기억을 하지 않아도 됩니다.
당장 만들 코드는 조금 증가하지만 코드 분석 시간은 상대적으로 감소하게 됩니다.
매개변수를 위한 타입을 만든다면 아래처럼 됩니다.
UpdateReq
에는 someId
와 OtherId
의 필드만 가지고 있습니다.
public void update(UpdateReq req) {
Some s = getSome(req.getSomeId());
// OtherDao.update만을 위한 매개변수
OtherUpdate otu = OtherUpdate.builder()
.id(req.getOtherId())
.key(s.getKey())
.builder();
int ret = otherDao.update(otu);
if (ret == 1) {
// anyDao.insert만을 위한 매개변수
AnyData any = AnyData.builder()
.someId(s.getId()).ip(s.getIp())
.builder();
anyDao.insert(any);
}
}
결론
매개변수 타입을 만드는데 인색하지 말 것
- 여러 메서드를 위한 값을 한 매개변수 타입에 우겨 넣지 말 것
- 여러 메서드에서 필요한 값만 매개변수로 받을 것
요약
메서드 이름을 신중히 짓자(표준 명명 규칙, API 라이브러리, 개발자 커뮤니티 참고)
편의 메서드를 너무 많이 만들지 말자
매개변수는 4개 이하로 만들자(메서드 쪼개기, 도우미 클래스 만들기, 빌더 패턴 클래스 만들기)
작성하는 메서드가 객체 자신의 기능을 하고 있는지 생각해보자
여러 메서드에서 필요한 값만 매개변수로 받자