일 | 월 | 화 | 수 | 목 | 금 | 토 |
---|---|---|---|---|---|---|
1 | 2 | |||||
3 | 4 | 5 | 6 | 7 | 8 | 9 |
10 | 11 | 12 | 13 | 14 | 15 | 16 |
17 | 18 | 19 | 20 | 21 | 22 | 23 |
24 | 25 | 26 | 27 | 28 | 29 | 30 |
- 글쓰는또라이
- 글또 OT 후기
- Git
- 오프라인밋업
- 스프링 핵심 원리 - 고급
- 검색 도메인
- BOJ
- 코딩테스트
- 자바
- 백준
- 글또 다짐
- 스프링배치 5
- 자바17
- 글또OT
- 코드트리
- 개발자
- 글또9기
- 글또후기
- SSAFY
- 유데미
- 검색도메인
- 제네릭
- 배치
- 알고리즘
- 글또
- 카프카강의
- 코드트리x글또
- 자바21
- 검색개발
- 글또10기
- Today
- Total
영주의 개발노트
과거 프로젝트 리팩토링해보기 (과거의 나 vs 현재의 나) 본문
문득 '과거의 나와 지금의 나는 얼마나 다른가?'가 궁금해졌다. '성장했다는 걸 명확하게 알 수 있는 부분이 없을까?' 하고 곰곰이 생각해 보았다. 과거 취준 시절에 만들었던 프로젝트를 리팩토링 해보면 어느 정도 달라졌는지 알 수 있을 것 같다는 생각이 들었다. 그렇게 시작된
나와 나의 대결!
들어가기 앞서 대상을 명확하게 짚고 넘어가겠다. 과거의 나는 취업준비 시절의 나이다. 현재 내가 생각하는 그때와 지금의 나에 대해 정리해 보았다.
과거 | 현재 |
✅ 실무경험 0 ✅ 클린코드, 객체지향이 뭐죠? 처음 들어봐요 ✅ 일단 되게 하라. 그러면 똥을 싸도 박수쳐줄 것이다. 💩 |
✅ 실무경험... 그래도 존재 ✅ 클린코드 책 읽어봄, 이펙티브자바 책 읽어봄, 모던자바 읽어봄 (일단 읽어는 봄) ✅ 코드 하나하나의 중요성에 대해 배움 |
간단히 말해 과거에는 코드를 클린하게 짜는 것에 대한 개념 자체가 없었다. 그냥 기능만 동작하면 장땡이었다. 현재는 기능 동작도 중요하지만, 더 나은 구조에 대해 고민하며 개발하고 있다.
이제 리팩토링할 프로젝트를 소개하겠다. 기존 개발 코드는 아래 링크에서 확인 가능하다. 개발 당시 욕심이 생겨 다양한 기능을 추가하고 대부분의 팀원 모두 처음 사용하는 Unity 개발을 해야 했기에 그나마 익숙했던 자바 관련 코드는 정말 말 그대로 돌아가게만 구현했다.
물론 보완해야 할 부분이 많은 코드이지만 모두 리팩토링하기에는 양이 많기에 코드 전반적으로 나타나는 부분에 대한 것을 리팩토링할 예정이다. 실무를 하지 않았다면 몰랐을 지식을 포함해 지금까지 공부했던 지식들을 토대로 총 4가지 키워드를 추려보았다. 내가 하는 리팩토링이 모두 옳다고 할 수는 없다. 현재 나의 지식 상태에서 나온 코드 리뷰정도로 봐주면 좋겠다. 이제 하나씩 살펴보자.
- 로그 사용
- setter 사용 지양
- 메서드 분리
- 생성자 주입 사용
1. 로그 사용
기존에는 아래 코드처럼 확인하고자 하는 정보들을 System.out.println()과 같은 시스템 콘솔을 사용하여 출력했다. 이를 로그 라이브러리를 사용해 로그를 사용하는 형식으로 변경하는 것이 좋겠다.
실무 경험이 없을 때에는 로그를 굳이 왜 써야 하는지 몰랐다. 아무도 알려주지 않았다. 로그에 대해 간단하게 정리해 보고 시스템 콘솔 출력 대신 로그를 사용해야 하는 이유에 대해 정리해 보겠다.
우선 로그는 레벨이 존재한다. 해당 레벨을 개발자가 상황에 맞게 선택하여 로그를 남길 수 있다.
- TRACE
- DEBUG
- INFO
- WARN
- ERROR
또한, 로그가 출력되는 결과에는 아래와 같은 항목들이 포함될 수 있다.
- 시간
- 로그 레벨
- 프로세스 ID
- 쓰레드명
- 클래스명
- 로그 메세지
시스템 콘솔을 사용하여 출력하는 대신 로그를 사용한다면 여러 장점이 존재한다.
- 쓰레드 정보, 클래스 이름과 같은 부가 정보를 함께 확인할 수 있고, 출력 모양을 조정할 수 있다.
- 로그 레벨에 따라 개발 서버에는 모든 로그를 출력하고, 운영 서버에는 출력하지 않는 등 로그를 상황에 맞게 조절할 수 있다.
- 시스템 아웃 콘솔에만 출력하는 것이 아닌 파일, 네트워크 등 별도의 위치에 남길 수 있다.
- 성능이 System.out 보다 좋다 (내부 버퍼링, 멀티 쓰레드, ...)
위와 같은 장점들이 있기에 로그를 사용해야 하는 것이다. 현재의 나라면 기존에 System.out을 사용했던 부분들을 모두 로그를 사용하도록 바꾸고, 일자별로 해당 로그를 분리하여 개발할 것이다.
2. setter(수정자) 사용 지양
프로젝트 코드를 살펴봤을 때 정말 많은 setXXX()을 호출하고 있었다. BoardService에서 게시글을 수정하는 메서드 일부를 발췌했다. 여기서 board는 수정할 게시글이다.
Board board = boardRepository.getById(boardId);
if(board != null) {
// 게시글 이미지 관련 코드 생략
board.setContent(boardRegisterPostRequest.getContent());
board.setTitle(boardRegisterPostRequest.getTitle());
Code code = codeService.getCodeByCode(boardRegisterPostRequest.getCode());
board.setCode(code);
boardRepository.save(board);
}
수정할 게시글을 찾고, 수정하고자 하는 내용으로 변경하고 저장한다. 해당 코드는 절차지향적인 방식으로 작성하여 setter 사용이 자연스럽다. 이를 객체지향적인 방식으로 변경해 보면 보는 관점부터 바꾸어야 한다. BoardService에서는 수정할 게시글을 찾기만 하고, 수정하고자 하는 내용 바탕으로 게시글을 수정하는 건 Board 자기 자신에게 맡기는 것이다. 즉, 아래처럼 변경하면 setter 사용을 하지 않을 수 있다.
Board board = boardRepository.getById(boardId);
if(board != null) {
// 게시글 이미지 관련 코드 생략
Code code = codeService.getCodeByCode(boardRegisterPostRequest.getCode());
board.update(boardRegisterPostRequest, code);
// 혹은
board.update(boardRegisterPostRequest.getContent(), boardRegisterPostRequest.getTitle(), code);
boardRepository.save(board);
}
이렇게 되면 실질적인 게시글 내용들을 수정하는 책임을 BoardService가 아닌 Board 가 지게 된다. BoardService에 부여된 역할이 Board로 넘어간 것이다. 최대한 setter 사용을 지양하고 가능하면 생성자를 통해 초기화하는 것이 좋다. 위 코드도 board 객체의 상태값을 변경하므로 추후 보완해야 할 부분이 있지만 여기서는 setter 사용을 하지 않았다는 것에 만족하겠다.
그렇다면 왜 setter를 지양해야 할까? 여러 이유가 존재하겠지만, 내 머릿속에 있는 정보들을 토대로 적어보겠다.
개발자는 사람이다. 사람이다 보니 실수하는 경우가 발생할 수 있다. 실무에서는 하나의 클래스에 굉장히 많은 필드값이 존재할 수 있다. 해당 필드들을 모두 setter로 하나하나 변경하려고 하다 보면 한 개를 빼먹을 수도 있다. 이러한 부분들은 컴파일 시점에는 찾기 어렵고, 디버깅해 봐야 알 수 있는 것들이다. 이를 생성자를 사용해 초기화한다면 빼먹은 부분을 컴파일 시점에 바로 알 수 있다.
또한, 객체지향원칙 중 하나인 OCP에 위배된다. setter를 사용하면 BoardService에서도 Board 상태를 변경할 수 있고, 다른 여러 객체들에서도 Board 상태를 변경할 수 있다. 변경 지점이 이곳저곳 많이 생기게 된다. setter 사용을 지양하고 생성자 등을 사용하여 객체 상태를 변경할 수 있는 지점을 최소화해야 한다.
3. 메서드 분리
우선, 아래 메서드를 살펴보자.
/**
* 요청한 회원, 미션 정보를 토대로
* 1. 미션 로그 수정
* 2. 동물 로그 추가
* 3. 맵 로그 추가
* 4. 다음 미션 코드 변경
* @param user
* @param mission
*/
public void completeMission(User user, Mission mission) {
// 1. 미션 로그 수정
MissionLog missionLog = updateMissionLog(user, mission, "C04");
// 다음 미션이 존재할 때
if(mission.getNextMission() != null && mission.getNextMission() != "") {
String[] nextMissions = mission.getNextMission().split("/");
for (String id : nextMissions) {
System.out.println(id);
Mission nextMission = missionRepository.getById(id);
updateMissionLog(user, nextMission, "C02");
}
}
// 2. 동물 로그 추가
// 3. 맵 로그 추가
String[] results = mission.getResultId().split("/");
if(results[0].charAt(0) == 'M') { // 맵 해금과 같이 있는 경우
Map map = mapRepository.getById(results[0]);
addMapLog(user, map);
Animals animals = animalsRepository.getById(results[1]);
addAnimalLog(user, animals);
} else { // 동물 해금 2개일 경우
for(String result : results) {
Animals animals = animalsRepository.getById(result);
addAnimalLog(user, animals);
}
}
}
미션을 다하고 나서 수행하는 메서드임을 짐작할 수 있다. 만약 주석이 달려있지 않았다면, 미션 완료 시 실행되는 메서드 내부에서 무슨 일이 일어나고 있는지 짐작하기 어려웠을 것이다. 코드는 글과 같다. 글의 한 문장을 길게 가져간다면 읽는 독자들은 문맥을 파악하기 힘들 것이다. 코드도 마찬가지다. 하나의 메서드가 굉장히 긴 코드로 이루어진다면, 읽다가 흐름을 놓치게 된다. 하나의 메서드 내부에서 한꺼번에 많은 일을 하려고 하지 말고, 이를 분리하여 각각의 메서드에게 역할을 나눠주도록 하자. 위 코드에 메서드 분리를 적용해 보자면 아래와 같을 것이다.
/**
* 요청한 회원, 미션 정보를 토대로
* 1. 미션 로그 수정
* 2. 동물 로그 추가
* 3. 맵 로그 추가
* 4. 다음 미션 코드 변경
* @param user
* @param mission
*/
public void completeMission(User user, Mission mission) {
MissionLog missionLog = updateMissionLog(user, mission, "C04");
if(existsNextMission(mission) {
updateNextMission(mission.getNextMission());
}
String[] results = mission.getResultId().split("/");
Arrays.stream(results).forEach(this::addLog);
addLog(results);
}
private boolean existsNextMission(Mission mission) {
return mission.getNextMission() != null && mission.getNextMission() != "";
}
private void updateNextMission(String nextMission) {
String[] nextMissions = nextMission.split("/");
for (String id : nextMissions) {
Mission nextMission = missionRepository.getById(id);
updateMissionLog(user, nextMission, "C02");
}
}
/**
* 동물 혹은 맵 로그 추가
* addMapLog, addAnimalLog 내부 수정해야 함
*/
private void addLog(String result) {
if(result.chartAt(0) == 'M') {
addMapLog(user, result);
} else {
addAnimalLog(user, result);
}
}
주석 없이도 메서드를 분리함으로써 각각의 로직에 이름이 생기고 해당 이름으로 의미 파악이 좀 더 쉬워졌다. 메서드 분리의 필요성에 대한 이야기에 대해 조금 더 해보려고 한다.
현재 재직 중인 회사에서 개발자 교육을 진행하였는데, 이때 객체 지향 프로그래밍을 잘하기 위한 9가지 원칙인 객체 지향 생활 체조 원칙에 대해 알게 되었다. 이 중에서도 아래 원칙이 기억에 남는다.
- 한 메서드에 오직 한 단계의 들여 쓰기만 한다.
정말 구체적이다. 사실 코드를 짜다보면 하나의 메서드에 주렁주렁 if, for, while 등의 코드들이 들어가게 된다. 그러다 보면 작성하면서도 '이게 맞나...'라는 생각이 들게 되는데 위 원칙을 지키고자 노력하며 코드를 짜다보면 길었던 메서드가 점점 짧아지기 시작한다. 더 나아가 메서드의 역할이 축소될 것이다.
4. 생성자 주입 사용
해당 프로젝트는 @Autowired를 이용한 필드 주입을 하고 있다.
@RestController
@RequestMapping("/api/survey")
public class SurveyController {
@Autowired
SurveyService surveyService;
...
}
필드 주입보다 생성자 주입을 하는 것이 좋다. 리팩토링한 코드는 아래와 같다.
@RestController
@RequestMapping("/api/survey")
@RequiredArgsConstructor
public class SurveyController {
private final SurveyService surveyService;
...
}
// 또는
@RestController
@RequestMapping("/api/survey")
public class SurveyController {
private final SurveyService surveyService;
public SurveyController(SurvyeService surveyService) {
this.survyeService = surveyService;
}
...
}
어떤 방식을 사용하든 생성자 주입을 이용하는 것이 포인트이다.
사실 기존 코드는 엄청한 문제점이 하나 더 있다. SurveyController 객체의 surveyService를 다른 객체에서 변경할 수 있다는 것이다. 이는 @RequiredArgsConstructor를 사용하면 바로 해결할 수 있다. 왜냐하면 @RequiredArgsConstructor를 이용한 생성자 주입은 final 필드에만 적용되기 때문이다. 생성자 주입을 사용해야 하는 이유 첫 번째이다. surveyService 같은 빈이 변경되는 것을 미연에 방지할 수 있다. @RequiredArgsConstructor를 사용하지 않더라도 @Autowired 와는 달리 final 사용할 수 있다.
또 다른 이유로는 순환 참조를 방지할 수 있다는 점이다. 만약 아래처럼 A와 B가 서로를 참조하고 있다고 가정해 보자.
// 1번 코드
@Bean
public A {
private B b;
public void methodA() {
b.methodB();
}
}
@Bean
public B {
private A a;
public void methodB() {
a.methodA();
}
}
그리고 애플리케이션에서 이 둘과 관련된 로직을 호출한다.
// 2번 코드
@SpringBootApplication
public class Application implements CommandLineRunner {
@Autowired
private A a;
@Autowired
private B b;
@Override
public void run(String... args) {
a.methodA();
b.methodB();
}
public static void main(String[] args) {
SpringApplication.run(DemoApplication.class, args);
}
}
만약 1번 코드에 의존성 주입 방식을 @Autowired를 이용한 필드주입으로 한다면, 아래 2번 코드의 문제점은 애플리케이션이 실행되고 나서야 해당 문제점을 발견할 수 있다. 즉, 런타임 시점에 문제가 있는 코드가 호출되고 나서야 에러가 터진다. 참고로 에러 중 제일 안 좋은 에러는 런타임에러이다. 하지만, 1번 코드에 생성자 주입 방식을 사용한다면, BeanCurrentlyCreationException이 발생하면서 애플리케이션이 구동조차 되지 않는다.
위와 같은 이유로 우리는 여러 의존성 주입 방식 중 생성자 주입을 선택해야 하는 것이다.
📝 글을 마무리하며
나와 나의 대결이라는 주제로 이전에 진행했던 프로젝트 코드들을 리팩토링해보았다. 4개의 키워드를 추려 정리해 봄으로써 'XXX해라'라는 문장에 대한 이유를 스스로 고민해 볼 수 있었다. 또한, 업무하며 잊고 살았던 객체 지향의 중요성에 대해 다시금 깨닫게 되었다. 최근 주니어 개발자로서 '내가 팀에서 무슨 일을 할 수 있을까?'라는 것을 고민했었다. '한 메서드에 오직 한 단계의 들여 쓰기만 한다.'처럼 구체적인 원칙을 세워 리팩토링을 함으로써 업무 코드도 좀 더 익히고 우리 팀 코드가 읽기 쉬운 코드가 될 수 있도록 해봐야겠다는 생각이 든다.
참고
'🌱' 카테고리의 다른 글
글또 9기를 마무리하며 (0) | 2024.05.12 |
---|---|
유데미(Udemy) | Apache Kafka 시리즈 – 초보자를 위한 아파치 카프카 강의 v3 수강후기 (1) | 2024.04.28 |
유데미(Udemy) | 【한글자막】 Linux Command Line 부트캠프: 리눅스 초보자부터 고수까지 수강후기 (0) | 2024.04.14 |
코드트리로 알고리즘 분할정복하며 타파 | 코드트리 8주 사용기 (0) | 2024.03.31 |
김영한님 오프라인 밋업 특별 참가 신청 (0) | 2024.01.17 |