리팩토링
개발개발 ·
템플릿 메서드 패턴
템플릿 메서드 패턴은 무엇인가
작업의 일부만 서브클래스로 떼어내, 전체 구조는 그대로 두면서 특정 단계의 내용만 바꾸는 패턴이다.
쓰면 중복 코드가 줄고, 자식 클래스가 맡는 몫이 작아져 핵심 로직을 관리하기 쉬워진다. 코드를 객체지향적으로 구성할 수 있다는 것도 장점이다.
반대로 추상 메서드가 많아지면 클래스를 관리하기 힘들어지고, 클래스들의 관계와 코드가 꼬일 염려가 있다.
클라이언트가 알고리즘의 특정 단계만 제어하고 전체 구조는 바꾸지 못하게 하고 싶을 때, 또는 한 단계만 구현이 다르고 나머지는 똑같이 동작할 때 쓰면 좋다.
구조

예제
특정 비즈니스 로직의 실행 시간을 알고 싶은 상황이다. 그래서 로직의 처음과 끝에 시간을 재는 코드를 넣을 것이다.
1. AbstractTemplate 생성 (추상 클래스)
@Slf4j
public abstract class AbstractTemplate {
public void execute() {
long startTime = System.currentTimeMillis();
//비즈니스 로직 실행
call(); //상속
//비즈니스 로직 종료
long entTime = System.currentTimeMillis();
long resultTime = endTime - startTime;
log.info("resultTime = {}", resultTime);
}
protected abstract void call();
}
추상 클래스 자체에 대한 설명은 이 글에서 생략한다. 자세한 내용은 https://coding-review.tistory.com/115 참조.
2. SubClassLogic1, SubClassLogic2 클래스 생성
@Slf4j
public class SubClassLogic1 extends AbstractTemplate {
@Override
protected void call() {
log.info("비즈니스 로직1 실행");
}
}
@Slf4j
public class SubClassLogic2 extends AbstractTemplate {
@Override
protected void call() {
log.info("비즈니스 로직2 실행");
}
}
3. 테스트 코드 작성
@Test
void templateMethodV1() {
AbstractTemplate template1 = new SubClassLogic1();
template1.execute();
AbstractTemplate template2 = new SubClassLogic2();
template2.execute();
}
4. 실행 결과

전략 패턴
전략 패턴이란 무엇인가
객체가 할 수 있는 각 행위마다 전략 클래스를 만들고, 비슷한 행위들을 인터페이스로 묶는 패턴이다. 객체의 행위를 바꾸고 싶을 때 행위를 직접 고치지 않고 전략만 갈아 끼우면 되므로 유연하게 확장할 수 있다.
변하지 않는 부분은 `Context`에 두고, 변하는 부분은 `Strategy` 인터페이스로 만들어 구현하게 한다. 상속이 아니라 위임으로 푸는 방식이다.
전략을 추가해도 기존 코드를 바꾸지 않아도 되고, 상속을 강요하지 않는다. 상속을 강요하지 않는다는 건 상속이 정말 필요한 곳에 상속을 아껴 쓸 수 있다는 뜻이다.
런타임에 전략을 바꾸고 싶을 때 쓴다.
구조

예제
1. Strategy 인터페이스를 만든다
public interface Strategy {
void call();
}
2. 구현체 StrategyLogic1, StrategyLogic2를 만든다
@Slf4j
public class StrategyLogic1 implements Strategy {
@Override
ipublic void call() {
log.info("비즈니스 로직1 실행");
}
}
@Slf4j
public class StrategyLogic2 implements Strategy {
@Override
ipublic void call() {
log.info("비즈니스 로직2 실행");
}
}
3. 변하지 않는 부분인 Context를 만든다
@Slf4j
public class Context {
private Strategy strategy;
ipublic Context(Strategy strategy) {
this.strategy = strategy;
}
public void execute() {
long startTime = System.currentTimeMillis();
//비즈니스 로직 실행
strategy.call();
//비즈니스 로직 종료
long endTime = System.currentTimeMillis();
long resultTime = endTime - startTime;
log.info("resultTime = {}", resultTime);
}
}
4. 스프링 빈으로 등록하거나, 쓸 때 구현체를 적절히 바꿔 끼운다
@Test
void strategy() {
Strategy strategyLogic1 = new StrategyLogic1();
Context context1 = new Context(strategyLogic1);
context.execute();
Strategy strategyLogic2 = new StrategyLogic2(0;
Context context2 = new Context(strategyLogic2);
context2.execute();
}
템플릿 메서드 패턴과 무엇이 다른가
전략 패턴과 상당히 비슷해 보이는 패턴이 있다. 바로 템플릿 메서드 패턴이다(https://coding-review.tistory.com/116?category=1053742).
구조로 보면 이렇게 갈린다. 템플릿 메서드 패턴은 부모 클래스에 변하지 않는 템플릿을 두고 변하는 부분을 자식 클래스에 두어 상속으로 푼다. 전략 패턴은 변하지 않는 부분을 Context에 두고 변하는 부분을 Strategy 인터페이스로 만들어 위임으로 푼다.
한 줄로 정리하면 템플릿 메서드 패턴은 상속으로, 전략 패턴은 위임으로 문제를 해결한다.
데코레이터 패턴
데코레이터 패턴은 무엇인가
상황과 용도에 따라 객체의 책임을 동적으로 추가할 수 있는 패턴이다.
프록시를 이용해 부가 기능을 얹는 역할을 한다. 트랜잭션을 손쉽게 시작해 주는 `@Transactional` 같은 AOP들이 데코레이터 패턴으로 만들어진 것이다.
기존 코드를 고치지 않고도 행동을 확장할 수 있고, 구성과 위임을 통해 실행 중에 행동을 더할 수도 있다.
클래스의 요소를 계속 수정해 가며 쓰는 구조이거나, 일부 요소를 조합해서 쓰는 구조라면 잘 맞는다.
구조


예제
`Component` 인터페이스에 런타임 의존 관계를 계속 추가하는 방식으로 구현할 수 있다.
특정 문자열을 꾸미는 예제를 만들 것이다. `RealComponent`는 입력된 문자열을 그대로 출력하고, `MessageDecorator`는 그 문자열을 꾸며 준다.
차이가 잘 보이도록 데코레이터를 적용하기 전과 후로 나눠 작성한다.
1. Component 인터페이스 작성
public interface Component {
String operation();
}
2. RealComponent 작성
@Slf4j
public class RealComponent implements Component {
@Override
public String operation() {
log.info("RealComponent 실행");
return "data";
}
}
3. Client 클래스 작성
@Slf4j
public class DecoratorPatternClient {
private Component component;
public DecoratorPatternClient(Component component) {
this.component = component;
}
public void execute() {
String result = component.operation();
log.info("result = {}", result);
}
}
4. 데코레이터 적용 전 테스트 코드
@Slf4j
public class DecoratorPatternTest {
@Test
void noDecorator() {
Component realComponent = new RealComponent();
DecoratorPatternClient client = new DecoratorPatternClient(realComponent);
client.execute();
}
}
5. 실행 결과

6. MessageDecorator 추가
@Slf4j
public class MessageDecorator implements Component {
private Component component;
public MessageDecorator(Component component) {
this.component = component;
}
@Override
public String operation() {
log.info("MessageDecorator 실행");
String result = component.operation();
String decoResult = "*****" + result + "*****";
log.infoi("MessageDecorator 꾸미기 적용 전 = {}, 적용 후 = {}, result, decoResult);
return decoResult;
}
}
7. 테스트 코드에 추가
@Test
void decorator() {
Component realComponent = new RealComponent();
Component messageDecorator = new MessageDecorator(realComponent);
DecoratorPatternClient client = new DecoratorPatternClient(messageDecorator);
client.execute();
}
8. 실행 결과

프록시 패턴
프록시 패턴은 무엇인가
인터페이스를 두고, 실제 클래스가 들어갈 자리에 대리자 객체를 대신 넣는 패턴이다. 클라이언트는 자기가 받은 것이 실제 객체의 결과인지 대리 객체의 결과인지 알지 못한다.
왜 쓸까? 전처리·후처리를 얹기 좋고, 특정 메서드에 대한 접근을 통제할 수 있다.
단점은 런타임 의존 관계가 복잡해져 가독성이 떨어질 수 있다는 것이다.
지연 로딩을 쓸 때, 필요한 시점에 객체를 끼워 넣고 싶을 때, 보안상 특정 메서드의 접근을 제어하고 싶을 때 쓰면 좋다.
구조


예제
1. Subject 인터페이스를 만든다
public interface Subject {
String operation();
}
2. RealSubject 클래스를 만든다 (Subject의 구현체)
@Slf4j
public class RealSubject implements Subject {
@Override
public String operation() {
log.info("실제 객체 호출");
sleep(1000);
return "data";
}
private void sleep(int millis) {
try {
Thread.sleep(millis);
}
catch (InterruptedException e) {
e.printStackTrace();
}
}
}
3. ProxyPatternClient 클래스를 만든다
public class ProxyPatternClient {
private Subject subject;
public ProxyPatternClient(Subject subject) {
this.subject = subject;
}
public void execute() {
subject.operation();
}
}
4. 테스트 클래스를 만든다
public class ProxyPatternTest {
@Test
void noProxyTest() {
RealSubject realSubject = new RealSubject();
ProxyPatternClient client = new ProxyPatternClient(realSubject);
client.execute();
client.execute();
client.execute();
}
}
5. 실행 결과

client가 realSubject를 세 번 호출해 값을 조회하는 데 3초가 걸렸다.
그런데 이 데이터가 한 번 조회하면 변하지 않는 값이라면, 어딘가에 보관해 두고 이미 조회한 값을 쓰는 편이 성능에 좋다. 그게 캐시다.
프록시 패턴의 주 기능은 접근 제어이고, 캐시도 접근을 제어하는 방법 중 하나다. 이미 만들어 둔 로직은 하나도 고치지 않고 프록시로 캐시를 적용해 보자.
6. CacheProxy 클래스 만들기
@Slf4j
public class CacheProxy implements Subject {
private Subject target;
private String cacheValue;
public CacheProxy(Subject target) {
this.target = target;
}
@Override
ipublic String operation() {
log.info("프록시 호출");
if (cacheValue == null) {
cacheValue = target.operation();
}
return cacheValue;
}
}
`private Subject target` — 클라이언트가 프록시를 호출하면 프록시가 결국 실제 객체를 불러야 하므로, 내부에 실제 객체의 참조를 갖고 있어야 한다. 프록시가 호출하는 이 대상을 target이라 부른다.
`operation()` — `cacheValue`가 비어 있으면 실제 객체(target)를 호출해 값을 구하고, 그 값을 `cacheValue`에 저장한 뒤 반환한다. 값이 이미 있으면 실제 객체를 전혀 호출하지 않고 캐시 값을 그대로 돌려준다. 그래서 첫 조회 이후에는 매우 빠르다.
7. 테스트 코드 작성
@Test
void cacheProxyTest() {
Subject realSubject = new RealSubject();
Subject cacheProxy = new CacheProxy(realSubject);
ProxyPatternClient client = new ProxyPatternClient(cacheProxy);
client.execute();
client.execute();
client.execute();
}
realSubject와 cacheProxy를 만들어 둘을 연결한다. 그러면 cacheProxy가 realSubject를 참조하는 런타임 의존 관계가 생긴다. 마지막으로 client에 realSubject가 아니라 cacheProxy를 주입한다.
이렇게 `client → cacheProxy → realSubject`가 완성된다. `cacheProxyTest()`는 `client.execute()`를 세 번 호출하는데, 이번에는 클라이언트가 realSubject가 아니라 cacheProxy를 호출하게 된다.
정리 — 데코레이터 패턴과 무엇이 다른가
여기까지 보면 어디서 많이 본 방식이다. 프록시를 적용하기 전에는 실제 객체를 반환하고, 적용한 뒤에는 실제 객체를 주입받아 꾸며 주는 방식 — 데코레이터 패턴과 똑같다.
프록시로 할 수 있는 일은 크게 둘로 나뉜다.
1. 접근 제어
• 권한에 따른 접근 차단
• 캐싱
• 지연 로딩
2. 부가 기능 추가
• 원래 서버가 제공하는 기능에 더해 부가 기능을 수행한다.
• 예) 실행 시간을 재서 로그를 남긴다.
GOF 디자인 패턴에서는 프록시 패턴과 데코레이터 패턴 둘 다 프록시를 쓰기 때문에, 구분 기준으로 의도를 택했다.
즉 접근 제어가 목적이면 프록시 패턴, 새로운 기능 추가가 목적이면 데코레이터 패턴이라 부른다.
핵심은 둘 다 프록시를 쓰지만 의도가 다르다는 점이다. 이름이 프록시 패턴이라고 해서 이 패턴만 프록시를 쓰는 것은 아니다.
코딩 스탠다드 카테고리 시작!
요즘 클린 코드에 빠져 있습니다. 그래서 2년쯤 된 사이드 프로젝트의 오래된 코드들도 대대적으로 손보며 깔끔한 코드로 바꾸는 중입니다.
그러다 문득 떠오른 저만의 코딩 방식, "코딩 스탠다드"를 정리해 보려 합니다.
저는 제 글이 많이 읽히는 것을 바라지 않습니다. "기억보다 기록을"의 향로님처럼 많은 분이 봐 주시면 좋겠다는 마음도 있지만, 저는 최대한 검색 엔진에 노출되지 않게 글을 써 왔습니다.
눈에 띄는 걸 별로 좋아하지 않기도 하고, 나중에 제 사람들에게만 보여 주며 지식을 나누는 쪽을 좋아하기 때문입니다.
누군가는 헬기로 농약을 뿌려야 할 만큼 광활한 옥수수 농장을 운영하지만, 조용히 시골에서 텃밭을 가꾸는 사람도 있으니까요. 저는 텃밭을 가꾸는 쪽입니다.
이 카테고리는 다른 카테고리와 달리 최대한 간결하고 이해하기 쉽게 쓸 생각입니다. 나중에 다시 보며 성장을 체감하려는 목적도 있고, 클린 코드를 지향하는 지인들에게 코딩 스탠다드를 전하려는 목적도 있습니다.
서두가 길었네요. 본격적으로 시작하겠습니다!
코딩 스탠다드 1 : if 문의 조건식이 길다면 함수로 빼버린다.
제목 그대로, if 문의 조건이 길다면 함수로 빼는 것이 제 첫 번째 코딩 스탠다드입니다.
상황
장바구니에 담긴 상품 중 사이즈가 M인 것을 2개 사려는데 재고가 1개뿐이라면, 결제를 막아야 합니다.
흐름은 이렇습니다.
• 데이터베이스에서 내 장바구니를 가져온다.
• 장바구니 안 상품의 사이즈가 M인지 확인한다.
• 내가 고른 수량과 데이터베이스에 남은 수량을 비교한다.
• 재고가 더 많으면 결제를 진행한다.
Bucket myBucket = bucketRepository.findByLoginId(loginId);
String itemName = myBucket.getItem().getName()
Item myItem = itemRepository.findByItemName(itemName);
if (myBucket.getItem().getSize().equals("M")) {
if (myBucket.getItem().getCount() - myItem.getStock() > 0) {
// payment logic
}
}
한 번 합쳐 보면
if 문이 중첩되어 있으니 하나로 합칠 수 있어 보입니다.
Bucket myBucket = bucketRepository.findByLoginId(loginId);
String itemName = myBucket.getItem().getName()
Item myItem = itemRepository.findByItemName(itemName);
if (myBucket.getItem().getSize().equals("M") && myBucket.getItem().getCount() - myItem.getStock() > 0) {
// payment logic
}
들여쓰기는 하나 줄었지만 조건식이 굉장히 길어졌습니다.
혼자 개발하는 거라면 이대로 둬도 될지 모릅니다. 물론 1년 뒤에 보면 까먹으니 혼자 하더라도 리팩토링은 해야 한다는 게 제 생각입니다만.
함수로 빼면
길어진 조건식은 함수로 빼면 깔끔해집니다.
Bucket myBucket = bucketRepository.findByLoginId(loginId);
String itemName = myBucket.getItem().getName()
Item myItem = itemRepository.findByItemName(itemName);
if (isBucketCountOverZero()) {
// payment logic
}
private boolean isBucketCountOverZero() {
return myBucket.getItem().getSize().equals("M") && myBucket.getItem().getCount() - myItem.getStock() > 0
}
나중에 이 코드를 다시 봤을 때 함수 이름만으로 조건식을 짐작할 수 있어 훨씬 읽기 좋아졌습니다.
마치며
저는 if 조건식이 길어지면 나중에 해석하기가 굉장히 힘들어지기 때문에 반드시 리팩토링해야 한다고 보는 쪽입니다.
다만 모든 조건식을 메서드로 빼야 한다는 뜻은 아닙니다. 직관적으로 읽히지 않을 만큼 긴 경우에 빼는 것이지, 적당히 짧으면 그대로 둬도 됩니다.
대신 조건식이 중복된다면 반드시 메서드로 빼서 관리하는 편이 좋습니다.
코딩 스탠다드 2 : Optional 을 활용한 null 체크
이번엔 `Optional`로 null을 체크하는 코딩 스탠다드입니다.
`Optional` 자체에 대한 자세한 설명은 망나니개발자님의 글(https://mangkyu.tistory.com/203)을 참고해 주세요. 잘 정리되어 있습니다.
상황을 하나씩 가정하며 살펴보겠습니다.
상황 1 — 값이 있으면 안 되는 경우
회원가입 중 로그인 아이디의 중복을 확인해야 하는 상황입니다. 흐름은 이렇습니다.
• 회원 데이터베이스에서 로그인 아이디로 회원을 조회한다.
• 존재하면 중복이므로 예외를 던지고, 존재하지 않으면 회원가입을 진행한다.
Member findMember = memberRepository.findByLoginId(loginId);
if (member == null) { // 중복 아님 = 회원가입 가능
Member member = new Member();
memberRepository.save(member);
}
else {
throw new IllegalStateException("중복된 아이디입니다.");
}
값이 있을 때 예외를 던져야 하므로 `Optional`의 `ifPresent()`를 쓰면 됩니다.
Optional<Member> findMember = memberRepository.findByLoginId(loginId);
findMember.ifPresent((member) -> {
throw new IllegalStateException("중복된 아이디입니다. 아이디 : " + member.getLoginId());
});
Member member = new Member();
memberRepository.save(member);
코드가 직관적이라 이해하기도 쉽습니다. "회원을 찾고, 만약 존재하면 예외를 던져라"처럼 영어를 읽듯 해석됩니다.
상황 2 — 값이 없으면 안 되는 경우
로그인 후 내 정보 보기에서 회원 정보를 화면에 뿌려 줘야 합니다.
• 현재 로그인한 아이디로 회원을 조회한다.
• 조회된 정보에서 이름을 화면에 뿌린다.
Member findMember = memberRepository.findByLoginId(loginId);
String memberName = findMember.getName();
return memberName;
이걸로 끝일까요? `findMember`가 null이면 `getName()`을 부르는 순간 `NullPointerException`이 터집니다.
이렇게 바꾸면 깔끔하게 막을 수 있습니다.
Optional<Member> findMember = memberRepository.findByLoginId(loginId);
Member optionalMember = findMember.orElseThrow(() -> new IllegalStateException("존재하지 않는 회원입니다."));
String memberName = optionalMember.getName();
return memberName;
상황 3 — List로 반환되는 경우
객체 하나를 반환할 때는 쉬운데, List로 반환되면 어떻게 할까요?
망나니개발자님 글에는 `Optional<List<Member>>`처럼 컬렉션을 Optional로 감싸지 말라고 나와 있습니다. 그럼 어떻게 해야 할까요?
List<Bucket> myBucket = bucketRepository.findByLoginId(loginId);
저는 팀원들에게 두 가지 방법을 제시합니다.
List<Bucket> myBucket = bucketRepository.findByLoginId(loginId);
for (Bucket bucket : myBucket) {
// 로직
}
이렇게 하면 List가 null일 때 for 문 자체가 돌지 않아 넘어갈 수 있습니다. 다만 이건 용인할 만한 정도이지 바람직하지는 않다고 생각합니다. 그래서 저는 유틸리티 클래스를 두고 빈 리스트를 반환하는 래퍼를 씁니다.
public class ListUtils {
public List<Bucket> getMyBucket() {
List<Bucket> myBucket = bucketRepository.findByLoginId(loginId);
if (myBucket == null) {
return Collections.emptyList();
}
else {
return myBucket;
}
}
}
이 유틸리티로 빈 리스트를 돌려주게 하면 null 관련 예외, 특히 NPE를 설계 단계에서 막을 수 있습니다.
상황 4 — 객체에 초깃값이 필요한 경우
데이터베이스에서 단일 객체를 조회했는데 없으면 초깃값을 세팅해 줘야 하는 경우가 종종 있습니다. 도메인에 초깃값에 해당하는 값을 두고 `Optional`의 `orElseGet`을 쓰면 됩니다.
class Member {
// 필드들
public Member init() {
this.name = "홍길동";
this.age = 27;
// etc...
return this;
}
}
마땅한 예시가 떠오르지 않아 회원 객체로 드는 점 양해 부탁드립니다.
class MemberService {
private final MemberRepositroy memberRepository;
public Member method(String loginId) {
Member findMember = memberRepository.findByLoginId(loginId);
if (findMember == null) {
return findMember.init(); // NPE 발생!
}
else {
return findMember;
}
}
}
여기서 NPE가 나므로 `Member` 객체를 하나 더 만들어야 합니다.
class MemberService {
private final MemberRepositroy memberRepository;
public Member method(String loginId) {
Member findMember = memberRepository.findByLoginId(loginId);
if (findMember == null) {
Member member = new Member();
return member.init();
}
else {
return findMember;
}
}
}
이렇게 하면 GC 문제가 걸립니다. 지금 `Member`가 두 개 만들어진 상태입니다. `findByLoginId`를 호출할 때 하나, null일 때 초기화용으로 하나. GC가 관리해야 할 객체가 둘인 것이죠. 이런 게 쌓이다 보면 무시하기 어려워집니다.
그래서 `orElseGet`을 씁니다.
class MemberService {
private final MemberRepositroy memberRepository;
public Member method(String loginId) {
Member findMember = memberRepository.findByLoginId(loginId).orElseGet(() -> {
Member member = new Member();
return member.init();
});
return findmember;
}
}
`orElseGet`은 `Optional`에 객체가 있으면 그 객체를 쓰고, 없을 때만 적어 둔 초기 객체를 세팅합니다.
`orElse`와의 차이는 이렇습니다. `orElse`는 값이 있든 없든 초기 객체를 만들지만, `orElseGet`은 없을 때만 만듭니다. 그래서 저는 `orElse`보다 `orElseGet`을 훨씬 많이 씁니다.
마치며
`Optional`로 null을 다루는 네 가지 상황을 정리해 봤습니다. 어쩌다 보니 `Optional` 문법 정리가 된 것 같네요.
이렇게 하면 값이 있으면 안 되는 경우와 있어야 하는 경우 모두 예외로 처리할 수 있습니다. 예외를 던지면 프론트에서 그대로 받아 메시지를 띄우면 됩니다.
프론트가 따로 없고 JSP나 타임리프 같은 템플릿 엔진으로 화면을 구성한다면 처리가 까다로운 건 사실입니다. 저는 예외를 던지고 받는 과정이 프론트와 백이 분리되어 있을 때 편해서, JSP로 개발하더라도 AJAX로 비동기 통신을 해 분리하는 쪽을 선호합니다.
이 글로 NPE와 안녕하는 삶을 누리시길 바랍니다.
코딩 스탠다드 3 : StreamAPI로 처리할 수 있는 반복문은 StreamAPI로 처리한다.
이번엔 for 문과 관련된 코딩 스탠다드입니다. StreamAPI를 다루니 관련 개념(https://coding-review.tistory.com/490)을 알고 계시면 좋습니다.
왜 "처리할 수 있는 것만" 인가
StreamAPI를 쓰면 코드가 확실히 간결해집니다. 들여쓰기 없이 한 줄에 모아 쓸 수 있어 보기 좋죠.
하지만 도입 전에 두 가지를 따져야 합니다.
• 성능
• 재사용성
우선 StreamAPI를 쓰면 연산 하나마다 O(n)이 붙어 최종적으로 O(an)이 됩니다. 원래 O(n)으로 끝났을 연산이 O(an)이 되면, 성능이 절실한 곳에서는 쓰기 까다롭습니다.
또 StreamAPI를 쓰면 재사용이 불가능해집니다. 한 번 써 보면 재사용은 포기한 코드라는 걸 바로 알게 됩니다.
둘을 모두 따졌는데도 도입해야 한다면, 성능이 크게 중요하지 않고 재사용할 일도 없는 경우겠죠. 혹은 들여쓰기가 너무 깊어져 성능을 조금 포기하더라도 가독성을 챙기는 경우일 겁니다.
상황
장바구니에 있는 상품의 상세 정보를 확인하고 재고를 따져 결제가 가능한지 판단하는 상황입니다.
• `ItemDetail`(상품 세부 정보)을 데이터베이스에서 조회합니다. 남은 재고 등이 여기 들어 있습니다.
• `TemporaryOrder`(장바구니 품목)를 데이터베이스에서 조회합니다.
• 반복문을 돌며 장바구니의 사이즈와 세부 정보의 사이즈를 비교합니다.
이 비교가 필요한 이유는 이렇습니다. 세부 정보에 S 10개, M 10개, L 2개가 있고 장바구니에서 L을 골랐다면, 고른 세부 정보와 상품의 세부 정보를 맞춰 봐야 하기 때문입니다.
• 고른 상품의 재고와 담은 수량을 비교해 0보다 작으면(2개뿐인데 3개 주문) 재고가 없다는 메시지를 띄웁니다.
• 아니라면 정상적으로 진행됩니다.
코드로 옮기면 이렇습니다.
Map<String, Object> map = new HashMap<>();
List<ItemDetail> findItemDetail = itemDetailRepository.findByItemId(1L);
TemporaryOrder findTemporaryOrder = temporaryOrderRepository.findByBucketId(1L); // 편의상 Optional을 사용하지 않았습니다.
for (ItemDetail itemDetail : findItemDetail) {
if (findTemporaryOrder.getSize().equals(itemDetail.getSize()) {
if (itemDetail.getStock() - findTemporaryOrder.getCount() < 0) {
map.put("checkStock", false);
map.put("message", "재고가 남아있지 않습니다. 상품이름 : " + itemDetail.getItem().getViewName() + "/ 남은 재고 : " + itemDetail.getStock());
}
}
}
map.put("checkStock", true);
return map;
읽기 힘드시죠? 일부러 복잡하게 만들었습니다. 극적으로 줄어드는 걸 보여 드리려고요.
우선 들여쓰기가 세 번이나 들어가 보기 싫습니다. 두 번으로 줄이려면 조건식 둘을 합쳐 메서드로 빼는 방법이 있지만, 우리가 원한 건 그 정도가 아니죠.
StreamAPI로 리팩토링
Map<String, Object> map = new HashMap<>();
List<ItemDetail> findItemDetail = itemDetailRepository.findByItemId(1L);
TemporaryOrder findTemporaryOrder = temporaryOrderRepository.findByBucketId(1L); // 편의상 Optional을 사용하지 않았습니다.
findItemDetail.stream()
.filter((itemDetail) -> findTemporaryOrder.getSize().equals(itemDetail.getSize()))
.filter((itemDetail) -> itemDetail.getStock() - findTemporaryOrder.getCount() < 0)
.findAny().ifPresent((itemDetail) -> {
map.put("checkStock", false);
map.put("message", "재고가 남아있지 않습니다. 상품이름 : " + itemDetail.getItem().getViewName() + "/ 남은 재고 : " + itemDetail.getStock());
});
map.put("checkStock", true);
어떤가요? 굉장히 깔끔해졌습니다.
마치며
앞에서 말했듯 StreamAPI는 가독성이 오르는 만큼 부작용도 있습니다. 성능과 재사용성 둘을 확인하고 도입을 고려해 보시면 좋겠습니다.
코딩 스탠다드 4 : 강타입 언어라면 강타입 언어의 장점을 적극 활용하자 (String 을 지양해야 하는 이유)
이번엔 방법론 이야기입니다.
프로그래밍 언어는 크게 강타입 언어와 약타입 언어로 나눌 수 있습니다. 흔히 말하는 로우레벨·하이레벨 구분과도 통하는 면이 있습니다.
강타입 언어는 타입이 딱딱 정해져 있는 언어입니다. 자바나 C 계열을 쓰신다면 `int`, `String`, `long`, `boolean` 같은 타입이 익숙하실 겁니다. C, C++, C#, Java 정도가 여기 속합니다. 더 있겠지만 제가 아는 선에서는 이 정도입니다.
약타입 언어는 타입이 런타임에 정해지는 언어입니다. 자바스크립트나 파이썬을 쓰신다면 익숙한 `var` 같은 것이죠.
이 글에서는 메서드로 변수를 넘길 때 강타입을 활용하는 방법을 다루고, 다음 글에서는 HTTP Request를 받을 때 활용하는 방법을 다루겠습니다.
자바는 강타입?
자바 몇 버전인지는 기억나지 않지만 자바도 약타입 비슷한 걸 지원합니다. 하지만 강타입 언어에는 강타입만의 장점이 있어, 굳이 그걸 써야 하나 싶은 생각이 듭니다.
상황
사업을 관리해야 하는데 기획자가 사업을 코드로 관리하자고 합니다. 3개 분과별로 사업 코드를 다르게 가져가고, 각 분과가 구별되게끔 코드를 만드는 것이 목표입니다.
• 먼저 분과를 받습니다.
• 분과에 맞는 코드를 생성합니다.
굉장히 간단하죠? 이번엔 복잡성을 줄이는 것보다 설계하는 과정을 보여 드려야 해서 일부러 쉬운 예제로 가져왔습니다.
public String createCode(String group) {
return makeCodeByGroup(group); // 이부분! 주목해주세요.
}
private String makeCodeByGroup(String group) {
String year = LocalDateTime.now().getYear().toString();
return year + "-" + group + "-" + UUID.randomUUID().toString();
}
메서드로 `group`이라는 변수를 넘기는 부분에 주목해 주세요. 여기에는 세 가지 문제가 있습니다.
• `group`이 뭐지?
• `group`에 뭐가 들어오는지 검사해야 하나?
• 최종적으로 어떤 코드가 만들어질까?
이 코드가 훨씬 복잡하고 로직이 꼬여 있는 상황을 상상해 보세요. 그냥 넘어가면 문제가 생길 수 있습니다. 그리고 이 코드를 처음 보는 사람이라면 저 물음들을 진지하게 고민하게 됩니다.
리팩토링
먼저 `group`이 무엇인지 어떻게 알려 줄 수 있을까요? 자바에는 강력한 타입이 있습니다. 바로 enum이죠.
@ToString
public enum Group {
NORMAL("일반"), MEDICAL("의료"), AI("AI");
Group(String description) {
}
}
그리고 반환할 DTO를 만듭니다.
@Data
public class CreateCodeByGroupDto {
private String year;
private Group group;
private String uuid;
public CreateCodeByGroupDto(String year, Group group, String uuid) {
this.year = year;
this.group = group;
this.uuid = uuid;
}
public String makeCode() {
return this.year + "-" + this.group.toString() + "-" + this.uuid;
}
}
다시 코드로 가 보죠.
public String createCode(Group group) {
return makeCodeByGroup(group); // 이부분! 주목해주세요.
}
private String makeCodeByGroup(Group group) {
String year = LocalDateTime.now().getYear().toString();
String uuid = UUID.randomUUID().toString();
CreateCodeByGroup code = new CreateCodeByGroup(year, group, uuid);
return code.makeCode();
}
클래스를 두 개 만드는 수고가 들었지만 세 문제가 모두 풀렸습니다.
`group`에 뭐가 들어오는지 궁금하면 `Group` 클래스를 열어 보면 되고, 거기 친절히 적어 둔 세 분과를 보게 됩니다. `makeCode()` 로직이 궁금하면 그것도 따라 들어가면 되죠. 그러니 무엇이 들어오는지 알게 되었고, 따로 검사할 필요도 없어졌습니다.
왜 굳이 이렇게 할까
조금 번거롭긴 합니다. 그런데도 이렇게 하는 이유는, `String` 타입이 워낙 자유분방해서 무엇이 들어올 수 있는지 일일이 명시하고 검사하려면 여간 골치 아픈 게 아니기 때문입니다.
혼자 만드는 사이드 프로젝트라면 `String`으로 받아도 상관없겠지만, 여럿이 협업하는 상황이라면 코드가 직관적이어야 하고 클라이언트에서 어떤 데이터가 넘어올지 모르기 때문에 `String`은 지양해야 합니다.
마치며
자바는, 특히 스프링은 정말 오밀조밀하게 잘 짜인 프레임워크이지만, 그 포맷을 일일이 따라가다 보면 1만큼 만들면 될 것을 10만큼 만들어야 하는 상황이 오더군요.
하지만 10만큼 다 하고 나면 애플리케이션이 점점 단단해지는 것을 느끼실 수 있을 겁니다.
다음 글에서는 이와 이어지는 HTTP Request 쪽에서 강타입을 활용해 안정성을 높여 보겠습니다.
코딩 스탠다드 5 : 강타입 언어라면 강타입 언어의 장점을 적극 활용하자 (HTTP Request)
이번엔 HTTP Request에서 강타입을 활용해 안정성을 높이는 방법입니다.
스프링은 주로 백엔드 서버로 쓰입니다. 수많은 API를 두고, API마다 프론트가 원하는 포맷으로 데이터를 내려 주죠. 보통 HTTP로 통신하니 RESTful하게 API를 만들고 Request를 JSON으로 받곤 합니다.
이때 자바의 강타입을 활용하면 큰 이득을 볼 수 있습니다.
이 글에서는 강타입으로 HTTP Request를 안정적으로 받는 방법과, 덤으로 어노테이션을 이용해 Validation을 효과적으로 처리하는 방법까지 살펴보겠습니다. 이번엔 버전을 올려 가며 리팩토링해 보죠.
상황
기획자가 회원가입에 이름, 나이, 성별, 휴대폰, 이메일, 아이디, 비밀번호 일곱 가지를 입력받으라고 합니다. 흐름도 없이 바로 코드로 보겠습니다.
Ver.1
@PostMapping("/member")
public String saveMember(HttpServletRequest request) {
String name = request.getParameter("name");
String age = request.getParameter("age");
String gender = request.getParameter("gender");
String phone = request.getParameter("phone");
String email = request.getParameter("email");
String loginId = request.getParameter("loginId");
String loginPw = request.getParameter("loginPw");
}
요즘 이렇게 코딩하시는 분은 없으시죠. JSP로 개발할 때나 이렇게 했지, 스프링을 쓰는 요즘은 이렇게 하지 않습니다.
Ver.2
스프링에서는 DTO를 두고 `@RequestBody`로 바인딩합니다.
@Data
public class SaveMemberDto {
private String name;
private String age;
private String gender;
private String phone;
private String email;
private String loginId;
private String loginPw;
}
@PostMapping("/member")
public String saveMember(SaveMemberDto dto) {
memberService.save(dto);
}
이 방법의 문제는 뭘까요? 편하긴 하지만 검증이 없습니다. 비밀번호에 영문자·숫자·특수문자가 들어갔는지, 휴대폰이 010-XXXX-XXXX 포맷인지, 이메일이 이메일 형식인지, 더 나아가 나이가 150을 넘지 않는지 등등 확인할 것이 많습니다.
Ver.3
@PostMapping("/member")
public String saveMember(SaveMemberDto dto) {
String passwordRegx = "비밀번호 정규표현식";
String emailRegx = "이메일 정규표현식";
String phoneRegx = "핸드폰 정규표현식";
if (!dto.getLoginPw().matches(passwordRegx)) {
return "돌아가";
}
if (!dto.getEmail().matches(emailRegx)) {
return "돌아가";
}
if (!dto.getPhone().matches(phoneRegx)) {
return "돌아가";
}
memberService.save(dto);
}
지금은 빈칸·공백·null은 신경도 쓰지 않고 정규표현식만 맞추고 있습니다. 실전에서는 그것들도 막아야 하니 코드가 훨씬 길어질 겁니다.
이 상황은 회원가입이라 저 로직을 전체에서 한 번만 만들면 되지만, 여러 번 검증해야 해서 중복이 생기면 어떡할까요? 저 로직을 계속 넣어 줘야 합니다.
Ver.4 — 커스텀 어노테이션 Validation
여기서 한 단계 더 올려 보죠. 커스텀 어노테이션은 난이도가 조금 있으니 자세한 내용은 따로 찾아보시길 권합니다.
@Target(ElementType.FIELD)
@Retention(RetentionPolicy.RUNTIME)
@Documented
@Constraint(validatedBy = PasswordCheckService.class)
public @interface Password {
}
`Password`라는 어노테이션을 만들었습니다. 안에 쓰인 어노테이션들을 간단히 짚고 넘어가겠습니다.
• @Target : 이 어노테이션이 필드에 붙는지, 메서드에 붙는지, 클래스에 붙는지를 지정합니다.
• @Retention : 어노테이션이 언제까지 유지될지 정합니다. `RUNTIME`으로 두면 나중에 리플렉션으로 동적 처리도 할 수 있습니다. 리플렉션 자체는 여기서 다루지 않겠습니다.
• @Documented : 이건 저도 정확히는 모르겠는데, 커스텀 어노테이션에 다들 붙이길래 붙이는 게 습관이 됐습니다.
• @Constraint : 가장 중요합니다. `validatedBy`에 선언한 `PasswordCheckService`의 로직에 따라 제약을 걸어 줍니다.
이제 `PasswordCheckService`를 만들어야겠죠.
public class PasswordCheckService implements ConstraintValidator<Password, String> {
@Override
public boolean isValid(String value, ConstraintValidatorContext context) {
String passwordPattern = "정규표현식 넣으세요.";
return !value.matches(passwordPattern);
}
}
PasswordCheckService는 ConstraintValidator를 구현한 클래스입니다. `isValid`를 구현하면서 제약 조건을 겁니다. 정규표현식과 맞지 않으면 false를 반환해 제약에 걸립니다.
@Data
public class SaveMemberDto {
@NotBlank
private String name;
@NotBlank
private String age;
@NotBlank
private String gender;
@NotBlank
private String phone;
@NotBlank
private String email;
@NotBlank
private String loginId;
@NotBlank
@Password // NEW!!
private String loginPw;
}
여기서 만든 커스텀 어노테이션을 쓰면 됩니다. `@NotBlank`도 함께 적었는데, 이건 기본 제공되는 어노테이션입니다.
`@NotNull` > `@NotEmpty` > `@NotBlank` 순으로 오른쪽으로 갈수록 제약이 강합니다.
• `@NotNull`은 null만 막습니다.
• `@NotEmpty`는 null과 `""`(빈칸)을 막습니다.
• `@NotBlank`는 가장 강해서 null, `""`(빈칸), `" "`(공백)을 모두 막습니다.
이제 컨트롤러에서 마무리합니다.
@PostMapping("/member")
public String saveMember(@Valid @RequestBody SaveMemberDto dto, BindingResult bindingResult) {
if (bindingResult.hasError()) {
return "돌아가";
}
memberService.save(dto);
}
코드가 한결 깔끔해졌습니다. `Password` 말고 `Email`, `Phone` 같은 어노테이션도 똑같이 만들어 적용하면 됩니다.
마치며
결국 하고 싶었던 이야기는 강타입 언어라면 강타입의 장점을 살리자는 것입니다. HTTP Request를 받을 때 그 장점을 활용하면 애플리케이션이 훨씬 탄탄해집니다.
이제 코딩 스탠다드 주제가 다 떨어졌습니다. 아는 건 다 써먹은 것 같네요. 일단 여기까지 하고 마무리 짓겠습니다. 감사합니다!
리팩토링 (0) : 개요
로버트 C. 마틴의 『클린 코드』를 읽었습니다. 책을 읽고 리팩토링의 중요성을 알게 되어 제 코드에 녹여 낼 생각입니다.
읽는 내내 무지성으로 작성한 제 코드들이 눈앞에 휙휙 지나가더군요. 얼른 다 읽고 리팩토링해 보고 싶습니다.
이 카테고리에서는 리팩토링을 어떻게 진행하는지 코드 레벨에서 설명합니다. 출처는 『클린 코드』입니다.
리팩토링으로 얻을 수 있는 것들과 그 과정에서 겪은 경험을 함께 나누겠습니다.
마지막으로 책의 첫 부분을 인용하며 시작하겠습니다.
"중복을 피하라, 한 기능만 수행하라, 제대로 표현하라, 작게 추상화 하라"
-로버트 C.마틴
리팩토링 (1) : 의미있는 이름
우리는 이름을 정말 많이 짓습니다. 클래스, 변수, 인터페이스, 추상 클래스, JAR 파일, WAR 파일까지 전부 이름을 붙이죠.
그러니 이름을 잘 붙이는 일은 그만큼 의미가 있습니다. 잘못 지은 이름은 가독성을 떨어뜨리기 마련입니다.
이번 글에서는 의미 있게 이름 붙이는 방법을 정리해 보겠습니다.
의미있는 이름
의도를 분명히 밝혀라
우리는 가끔 변수명을 이렇게 짓습니다.
int d;
개발할 당시에만 기억나고, 한참 뒤에 보면 무슨 변수인지 헷갈립니다.
그러니 이렇게 바꿔야 합니다.
int daysSinceModification;
조금 더 연장선에 있는 코드를 보시죠.
public List<int[]> getThem() {
List<int[]> list1 = new ArrayList<>();
for (int[] x : theList) {
if (x[0] == 4) {
list1.add(x);
}
}
return list1;
}
이 코드의 문제는 이렇습니다.
• `getThem`이 무엇을 가리키는지 모른다.
• `theList`에 무엇이 들어 있는지 모른다.
• 값 `4`가 무슨 의미인지 모른다.
• 함수가 반환하는 `list1`을 어떻게 써야 하는지 모른다.
그래서 우리는 변수를 두고 그 변수로 의도를 설명해 줄 필요가 있습니다.
위 상황은 사실 지뢰찾기 게임의 한 부분이었습니다. 그렇게 보면 `theList`가 게임판이라는 걸 알 수 있죠. 배열의 0번째에는 칸의 상태가 들어 있고, `4`는 깃발이 꽂힌 상태를 뜻합니다.
그럼 이렇게 리팩토링할 수 있습니다.
private static int STATUS_VALUE = 0;
private static int FLAGGED = 4;
public List<int[]> getFlaggedCells() {
List<int[]> flaggedCells = new ArrayList<>();
for (int[] x : gameBoard) {
if (x[STATUS_VALUE] == FLAGGED) {
flaggedCells.add(x);
}
}
return flaggedCells;
}
이렇게 고치면 다른 개발자가 봤을 때 어떤 의도로 짠 코드인지 알 수 있겠죠.
의미있게 구분하라
아무 생각 없이 붙이는 변수명에는 큰 의미가 없는 경우가 많습니다.
moneyAccount 와 int money 는 구분이 안된다.
customerInfo 와 customer 역시 구분 안된다.
accountData 와 account, theMessage 와 message 구분 X
의미 없는 미사여구는 변수명만 길게 할 뿐 의미를 분명하게 만들지 않습니다. 안티패턴이니 절대 하지 말라는 말은 아니지만, 명확한 이름은 아니라는 뜻입니다.
줄임말을 쓰지 말아라
private Date genymdhms;
private Date modydhms;
이게 무슨 변수인지 아시겠나요? 설명을 듣기 전에는 아무도 모를 겁니다.
private Date generationTimestamp;
private Date modificastionTimestamp;
이렇게 바꾸는 편이 훨씬 명확합니다.
검색하기 쉬운 이름을 사용하라
for (int j = 0 ; j < 34 ; j++) {
s += (t[j] * 4) / 5;
}
이것도 앞의 "의도를 분명히 하라"와 어긋나지만, 강조점이 조금 다릅니다.
이 메서드의 for 문을 찾으려고 `j`나 `s`를 검색하면 얼마나 많은 j와 s가 걸려 나올까요? 그래서 의도를 드러내면서 검색하기도 쉬운 이름을 써야 합니다.
private static final int WORK_DAYS_PER_WEEK = 5;
private static final int NUMBER_OF_TASKS = 34;
public static void main(String[] args) {
int realDaysPerIdealDay = 4;
for (int j = 0; j < NUMBER_OF_TASKS; j++) {
int realTaskDays = taskEstimate[j] * realDaysPerIdealDay;
int realTaskWeeks = (realTaskDays / WORK_DAYS_PER_WEEK);
sum += realTaskWeeks;
}
}
//생략된 변수가 있을 수 있습니다.
그릇된 정보를 피하라
한 모듈에서 XYZControllerForEfficientHandlingOfStrings 라는 긴 이름을 쓰고 있다고 해 봅시다. 그리고 조금 떨어진 모듈에서 XYZControllerForEfficientStorageOfStrings 를 쓰고 있다면, 이 둘이 구분되시나요?
너무 비슷해서 의미가 흐려집니다. 이런 이름은 피해야 합니다.
의미있는 맥락을 추가하라
어떤 모듈에서 이런 변수들을 쓰고 있다고 해 봅시다.
String firstName;
String lastName;
String street;
String houseNumber;
String city;
String state;
String zipcode;
죽 늘어놓고 보면 주소라는 걸 알 수 있습니다.
그런데 어떤 메서드에서 state 하나만 쓰고 있다면 어떨까요? 이 state가 주소의 일부라는 걸 알 수 있을까요?
그래서 이런 상황에서는 의미 있는 맥락을 붙여 주는 편이 좋습니다.
String addrFirstName;
String addrLastName;
String addrStreet;
String addrHouseNumber;
String addrCity;
String addrState;
String addrZipcode;
마치며
여기까지가 의미 있는 이름 부분입니다. 이름 짓기는 크게 어려운 것 없이 잘 지나간 것 같습니다. 그래도 조금 더 신경 써야 할 부분이 몇 개 보이네요.
모든 파트를 정리하고 제 프로젝트에 녹여 가면서 깨끗해질 코드를 기대해 보며 마무리하겠습니다.
긴 글 읽어 주셔서 감사합니다. 오늘도 즐거운 하루 되세요.
리팩토링 (2) : 함수
이번 주제는 함수입니다. 우리는 개발하면서 수십, 수백 개의 함수를 만들어 냅니다. 그런데 정작 함수가 어떻게 읽기 좋게 쓰이는지는 별로 고민하지 않는 제 모습을 봤습니다.
로버트 C. 마틴의 말을 인용하며 시작하겠습니다.
"함수를 만드는 첫번째 방법은 작게 만드는 것이다. 그리고 두번째 방법은 더 작게 만드는 것이다. 함수는 100줄을 넘어서는 안된다. 아니! 20줄도 넘어서는 안된다!" -로버트 C.마틴
함수
한 가지만 해라
함수 하나가 버퍼도 처리하고, 페이지도 가져오고, 상속된 페이지를 검색하고, 경로를 렌더링하고, 문자열을 덧붙이고, HTML 파일까지 만드는 만능 함수를 한 번쯤 만들어 보셨을 겁니다.
이에 대해 저자는 "함수는 한 가지를 해야하고, 그 한가지를 잘해야하며, 그 한가지만을 해야한다."라고 말합니다.
함수의 추상화 수준은 하나로
.getHtml()
이 코드는 추상화 수준이 굉장히 높습니다. 그럼 이 코드는요?
String pagePathName = PathParser.render(page.path);
추상화 수준이 중간입니다. 그럼 이건요?
.append("\n");
추상화 수준이 매우 낮습니다.
간단히 말하면 추상화 수준을 높일 거면 함수만 존재해야 하고, 중간으로 유지하고 싶으면 다른 수준을 섞지 말아야 한다는 뜻입니다. 수준이 일정하지 않으면 읽기 어려우니 맞춰 줘야 합니다.
Switch 문
switch 문은 애초에 한 가지 일만 할 수 없습니다(if-else도 마찬가지). 이런 경우 추상 클래스로 감싸 추상화해야 합니다.
public Money calculatePay(Employee e) throws InvalidEmployeeType {
switch (e.type) {
case COMMISSTION -> {
return calculateCommisstionPay(e);
}
case HOURLY -> {
return calculateHourlyPay(e);
}
case SALARIED -> {
return calculateSalariedPay(e);
}
default -> {
throw new InvalidEmployeeType(e.type);
}
}
}
추상 클래스로 리팩토링하는 방법은 https://coding-review.tistory.com/448 에서 자세히 다뤘습니다. 너무 길어질 것 같아 따로 뺐습니다.
서술적인 이름을 사용하라
서술적인 이름은 함수가 하는 일을 더 명확하게 드러냅니다. 예를 들어 `isTestable()`이나 `includeSetupAndTeardownPages()`처럼요.
"이름이 좀 길어도 괜찮다. 겁먹을 필요 없다. 길고 서술적인 이름이 짧고 어려운 이름보다 좋다." -로버트 C.마틴
함수 인수
이상적인 인자 개수는 0개입니다. 그다음이 1개, 그다음이 2개고, 3개는 가능하면 피하고 4개는 쓰지 말아야 합니다.
단항함수
단항 함수를 쓰는 경우는 둘로 나뉩니다.
• 인수에게 질문하는 경우 — `boolean fileExists("Myfile")`
• 인수를 무언가로 변환해 반환하는 경우 — `InputStream fileOpen("Myfile")`
이 둘을 빼면 단항 함수를 쓰지 않는 편이 좋습니다.
그리고 단항 함수 중 플래그 함수는 절대 쓰지 말라고 강조합니다. `render(true)`처럼 boolean을 넘기는 것을 말하는데, 읽기가 까다롭고 안을 들여다보지 않으면 무엇을 참으로 만드는지 알 수 없습니다.
이항함수
인자가 2개인 함수는 1개인 함수보다 이해하기 어렵습니다.
writeField(name) VS writeField(outputStream, name);
이 둘은 딱 봐도 가독성에서 차이가 납니다. 첫 인자인 `outputStream`은 내부 로직에서 쓰이는 것임을 알 수 있죠. 이런 경우엔 차라리 `OutputStream`을 상속해 `outputStream.writeField(name)`으로 바꾸는 편이 낫습니다.
물론 이항 함수가 더 잘 어울리는 경우도 있습니다. 바로 좌표입니다.
Point p = new Point(1, 2);
좌표는 둘인 편이 자연스러우니 이때는 이항 함수가 좋겠습니다.
이항 함수가 이해하기 어렵다는 대표적인 예가 JUnit의 `assertEquals`입니다.
assertEquals(expect, actual);
`assertEquals`는 첫 인자가 기대값, 두 번째가 실제값이라는 사실을 알고 있어야 헷갈리지 않습니다. 개발자라면 다 알지만 저도 가끔 멈칫할 때가 있었습니다. 이런 코드는 클린 코드가 아니라는 저자의 말이겠죠.
삼항함수
삼항 함수는 더 이해하기 어렵습니다. 되도록 쓰지 말자는 것이 저자의 말입니다.
인자가 2~3개로 늘어난다면 하나로 합치는 것도 고려해 볼 만합니다.
Circle makeCircle(double x, double y, double radius);
세 개인 인자를 아래처럼 바꿀 수 있습니다.
Circle makeCircle(Point center, double radius);
가독성 면에서는 이쪽이 더 낫습니다. 위 코드가 원래 쉬운 편이긴 하지만요.
명령과 조회를 분리해라
객체 상태를 바꾸거나 객체 정보를 반환하거나 둘 중 하나만 하는 편이 좋습니다. 맨 처음의 "한 가지만 해라"와 통하는 이야기입니다.
public boolean set(String attribute, String value);
if (set(username, "bob")) {
...
}
`set`이라는 함수가 boolean을 반환하고, 그걸 if 문에 넣었더니 뜻이 모호해졌습니다.
• username이 bob으로 바뀌어 있는지 확인하는 코드인가?
• username을 bob으로 바꾸는 코드인가?
if 문 때문에 "username이 bob으로 설정되어 있다면..."으로 읽힙니다. 그래서 이렇게 리팩토링해야 합니다.
if (attributeExists(username)) {
setAttribute(username, "bob");
}
오류 코드보다 예외를 사용해라
if (deletePage(page) == E_OK) {
if (registry.deleteReference(page.name) == E_OK) {
if (configKeys.deleteDey(page.name.makeKey()) == E_OK) {
logger.log("page deleted")();
}
else {
logger.log("config key is not deleted");
}
}
else {
logger.log("deleteReference from registry failed");
}
}
else {
logger.log("delete failed");
}
if 문에서 오류 여부를 확인하다 보니 굉장히 읽기 힘들어졌습니다. try-catch로 예외를 처리하면 이렇게 바꿀 수 있습니다.
try {
deletePage(page);
registry.deleteReference(page.name);
configKey.deleteKey(page.name.makeKey());
}
catch (Exception e) {
logger.log(e.getMessage());
}
저자는 여기서 멈추지 않고, try-catch를 쓴다면 반드시 한 줄로 요약하라고 말합니다. 바로 이렇게요.
try {
deletePageAndAllReferences(page);
}
catch (Exception e) {
logError(e);
}
private void deletePageAndAllReferences(Page page) throws Exception {
deletePage(page);
registry.deleteReference(page.name);
configKey.deleteKey(page.name.makeKey());
}
private void logError(Exception e) {
logger.log(e.getMessage());
}
마치며
이번엔 함수를 살펴봤습니다. 제 프로젝트에서 고쳐야 할 부분이 정말 많겠네요. switch 문을 추상 클래스로 리팩토링하는 부분은 직접 해 봐야겠다 싶어 실습까지 마쳤습니다.
객체지향 언어에서 다형성을 어떻게 쓰는지 감을 잡았습니다. 좋은 공부가 됐네요.
긴 글 읽어 주셔서 감사합니다. 오늘도 즐거운 하루 보내세요.
리팩토링 (2-1) : switch 문 인터페이스, 추상클래스로 리팩토링
이번엔 『클린 코드』에 나온 예제인 switch 문을 인터페이스와 추상 클래스로 리팩토링하는 방법을 다뤄 보겠습니다.
책에서는 두루뭉술하게 넘어가 잘 와닿지 않아서, 개인적으로 토이 프로젝트를 하나 만들어 봤습니다. 추상 클래스와 인터페이스는 최근에야 온전히 이해하기 시작해서 조금 미숙할 수 있습니다.
public Money calculatePay(Employee e) throws InvalidEmployeeType {
switch (e.type) {
case COMMISSTION -> {
return calculateCommisstionPay(e);
}
case HOURLY -> {
return calculateHourlyPay(e);
}
case SALARIED -> {
return calculateSalariedPay(e);
}
default -> {
throw new InvalidEmployeeType(e.type);
}
}
}
이런 switch 문이라면 다형성을 쓰기 딱 좋겠다는 생각을 책을 읽으며 했습니다. 다만 저는 인터페이스를 떠올렸지 추상 클래스는 떠올리지 못했습니다.
책에서 이런 경우 추상 클래스로 한 번 감싸는 게 좋다고 하니, 떠오르는 대로 리팩토링해 보겠습니다.
비즈니스 요구사항
• 우리 서비스는 외주, 시간제, 월급제 고용인을 씁니다.
• 각각 봉급을 지급하는 날짜가 다릅니다.
• 각각 봉급을 계산하는 방식이 다릅니다. (외주 = 건당, 시간제 = 시간당, 월급제 = 월당)
• 셋은 각각 다른 데이터베이스에 저장되어 있습니다.
상황
• 비즈니스 로직을 직접 구현하지는 않았습니다. 프로젝트가 괜히 커지는 걸 원치 않았습니다.
• 모든 비즈니스 로직은 로그로 대체합니다.
• 스프링을 쓰지 않았습니다. 실제로 어떻게 쓸지도 생각해 봤지만, 토이 프로젝트에서는 순수 자바로 다형성을 구현했습니다.
구조는 이렇습니다.

Employee.java
public abstract class Employee {
public abstract boolean isPayday();
public abstract Money calculatePay();
public abstract void deliverPay(Money pay);
}
calculatePay의 반환값인 Money 클래스는 구현하지 않고 빈 껍데기로 뒀습니다.
Money.java
public class Money {
}
EmployeeFactory.java
public interface EmployeeFactory {
public Employee makeEmployee(EmployeeRecord r) throws InvalidEmployeeType;
}
EmployeeFactoryImpl.java
public class EmployeeFactoryImpl implements EmployeeFactory {
@Override
public Employee makeEmployee(EmployeeRecord r) throws InvalidEmployeeType {
switch (r) {
case COMMISSION -> {
return new CommissionedEmployee(r);
}
case HOUR -> {
return new HourlyEmployee(r);
}
case SALARY -> {
return new SalariedEmployee(r);
}
default -> {
throw new InvalidEmployeeType("type is " + r);
}
}
}
}
EmployeeRecord.java
public enum EmployeeRecord {
COMMISSION, HOUR, SALARY, INVALID
}
구현체인 EmployeeFactory의 makeEmployee를 호출하면(현재 구현체는 EmployeeFactoryImpl) 추상 클래스를 구현한 CommissionedEmployee, HourlyEmployee, SalariedEmployee가 동작합니다.
이제 구현체들을 보시죠.
CommissionedEmployee.java
@Slf4j
public class CommissionedEmployee extends Employee {
private String record;
public CommissionedEmployee(EmployeeRecord record) {
this.record = record.name();
}
@Override
public boolean isPayday() {
System.out.println("커미션 고용인의 페이 날짜 계산 로직, 고용인 등급 : " + record);
return false;
}
@Override
public Money calculatePay() {
System.out.println("커미션 고용인의 페이 계산 로직, 고용인 등급 : " + record);
return null;
}
@Override
public void deliverPay(Money pay) {
System.out.println("커미션 고용인으로 계좌 송금, 고용인 등급 : " + record);
}
}
HourlyEmployee.java
@Slf4j
public class HourlyEmployee extends Employee {
private String record;
public HourlyEmployee(EmployeeRecord record) {
this.record = record.name();
}
@Override
public boolean isPayday() {
System.out.println("시간제 고용인의 페이 날짜 계산 로직, 고용인 등급 : " + record);
return false;
}
@Override
public Money calculatePay() {
System.out.println("시간제 고용인의 페이 계산 로직, 고용인 등급 : " + record);
return null;
}
@Override
public void deliverPay(Money pay) {
System.out.println("시간제 고용인으로 계좌 송금, 고용인 등급 : " + record);
}
}
SalariedEmployee.java
@Slf4j
public class SalariedEmployee extends Employee {
private String record;
public SalariedEmployee(EmployeeRecord record) {
this.record = record.name();
}
@Override
public boolean isPayday() {
System.out.println("월급제 고용인의 페이 날짜 계산 로직, 고용인 등급 : " + record);
return false;
}
@Override
public Money calculatePay() {
System.out.println("월급제 고용인의 페이 계산 로직, 고용인 등급 : " + record);
return null;
}
@Override
public void deliverPay(Money pay) {
System.out.println("월급제 고용인으로 계좌 송금, 고용인 등급 : " + record);
}
}
각각 외주, 시간제, 월급제에 대한 비즈니스 로직이 들어 있습니다.
실행
@Slf4j
public class Main {
public static void main(String[] args) {
EmployeeFactory factory = new EmployeeFactoryImpl();
try {
Employee employee = factory.makeEmployee(EmployeeRecord.COMMISSION);
employee.isPayday();
employee.calculatePay();
employee.deliverPay(null);
} catch (InvalidEmployeeType e) {
log.error("Invalid Employee Record, Record : {}", EmployeeRecord.INVALID);
throw new RuntimeException(e);
}
}
}
순수 자바로 구현했으니 main에서 실행해 보겠습니다. 인터페이스 EmployeeFactory의 구현체로 EmployeeFactoryImpl을 고른 뒤 makeEmployee를 COMMISSION으로 호출하면?

makeEmployee의 enum 타입만 바꿔 주면 다형성에 의해 구현체가 슥삭 바뀌는 모습을 볼 수 있습니다.


실전에서는
@Configuration
public class IfUseSpringEmployeeConfig {
@Bean
public EmployeeFactory employeeFactory() {
return new EmployeeFactoryImpl();
}
}
저는 수동으로 빈을 등록했습니다. 컴포넌트 스캔을 쓸 수도 있었지만, 다형성을 써야 할 때는 수동 등록이 OCP를 최대한 지키는 방법이라고 생각하기 때문입니다.
물론 Repository, Service, Controller 같은 것을 등록할 때는 컴포넌트 스캔이 편합니다. 제 생각엔 변경이 잦으면 수동으로, 잦지 않으면 컴포넌트 스캔이 좋습니다.
그리고 쓸 곳에서 EmployeeFactory를 주입받으면 빈으로 등록한 EmployeeFactoryImpl이 선택됩니다.
UseSpringFrameworkMain.java
@Service
@RequiredArgsConstructor
public class UseSpringFrameworkMain {
private final EmployeeFactory employeeFactory;
public void employeeCheck() {
try {
Employee employee = employeeFactory.makeEmployee(EmployeeRecord.COMMISSION);
employee.isPayday();
employee.calculatePay();
employee.deliverPay(null);
} catch (InvalidEmployeeType e) {
throw new RuntimeException(e);
}
}
}
생성자 주입으로 받았습니다. 주입받은 것이 인터페이스이므로 구현 클래스가 아니라 추상에 의존하게 되어 DIP에 위배되지 않습니다.
이제 우리가 만든 Service를 빈으로 등록하고 employeeCheck를 호출하면, main에서 돌렸던 것과 똑같이 동작합니다.
마치며
인프런 영한님 강의를 보면 이와 비슷한 방식으로 자바의 다형성을 이용해 OCP와 DIP를 철저히 지키는 내용이 나옵니다. 강의에서 추상 클래스를 쓰지는 않지만, 제가 구현한 내용은 그 방식과 정말 유사합니다.
제 글로 이해가 부족하다면 영한님의 스프링 핵심 원리 - 기본편 을 참고해 주세요.
이렇게 다형성으로 클린 코드를 구축해 봤습니다. 긴 글 읽어 주셔서 감사합니다. 오늘도 즐거운 하루 되세요.
JWT 필터 리팩토링
이번엔 제 프로젝트의 JWT 필터를 리팩토링해 보겠습니다. 먼저 제 코드를 보여 드릴 텐데, 정말 엉망입니다. if-else if-else를 몇 번이나 반복한 건지 모르겠네요.
『클린 코드』에서 얻은 개념을 바탕으로 리팩토링해 보겠습니다.
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {
// 1. Request Header 에서 JWT 토큰 추출
String token = resolveToken((HttpServletRequest) request);
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
HttpServletRequest httpRequest = (HttpServletRequest) request;
// 2. validateToken 으로 토큰 유효성 검사
if (token != null && isValidJwtFormat(token) && jwtTokenProvider.validateToken(token)) {
// Redis 에 해당 accessToken logout 여부 확인
String isLogout = (String) redisTemplate.opsForValue().get(token);
if (ObjectUtils.isEmpty(isLogout)) {
// 토큰이 유효할 경우 토큰에서 Authentication 객체를 가지고 와서 SecurityContext 에 저장
Authentication getAuthentication = jwtTokenProvider.getAuthentication(token);
HttpSession session = httpRequest.getSession();
session.setAttribute("loginId", getAuthentication.getName());
SecurityContextHolder.getContext().setAuthentication(getAuthentication);
}
}
else if (authentication != null && authentication.isAuthenticated()) { // 3. access token 이 만료된 상황
// token 은 만료 되었으나 인증이 되어있는 사용자 = 아직 페이지를 안벗어났지만 token 의 유효시간은 끝난 사용자
UserResponseDto tokenInfo = (UserResponseDto) redisTemplate.opsForValue().get("RT:" + authentication.getName());
if (tokenInfo != null) {
setNewAuthentication((HttpServletResponse) response, authentication);
SecurityContextHolder.getContext().setAuthentication(authentication);
}
}
else { // 4. access token 만료되고 Authentication 객체도 null 인 상황
String sessionId = getSessionId((HttpServletRequest) request);
Authentication authenticationFromSessionId = null;
RedisAndSession redisAndSession = null;
if (sessionId != null) {
redisAndSession = redisTokenRepository.findBySessionId(sessionId).orElse(null);
}
if (redisAndSession != null) { // refresh token 이 존재하는 경우 존재하지 않으면 로그인이 풀린다.
Member findMember = memberRepository.findBySessionId(sessionId).orElse(null);
User findUser = userRepository.findBySessionId(sessionId).orElse(null);
if (findMember == null && findUser != null) { // User 객체만 존재할 경우
Collection<? extends GrantedAuthority> authorities =
Arrays.stream(findUser.getRole().toString().split(","))
.map(SimpleGrantedAuthority::new)
.collect(Collectors.toList());
UserDetails principal = new org.springframework.security.core.userdetails.User(findUser.getEmail(), "", authorities);
authenticationFromSessionId = new UsernamePasswordAuthenticationToken(principal, "", authorities);
setNewAuthentication((HttpServletResponse) response, authenticationFromSessionId);
SecurityContextHolder.getContext().setAuthentication(authenticationFromSessionId);
}
else if (findUser == null && findMember != null) { // Member 객체만 존재할 경우
Collection<? extends GrantedAuthority> authorities =
Arrays.stream(findMember.getRole().toString().split(","))
.map(SimpleGrantedAuthority::new)
.collect(Collectors.toList());
UserDetails principal = new org.springframework.security.core.userdetails.User(findMember.getUsername(), "", authorities);
authenticationFromSessionId = new UsernamePasswordAuthenticationToken(principal, "", authorities);
setNewAuthentication((HttpServletResponse) response, authenticationFromSessionId);
SecurityContextHolder.getContext().setAuthentication(authenticationFromSessionId);
}
else if (findMember != null && findUser != null) { // 둘 다 존재하는 경우 우선순위는 Member
Collection<? extends GrantedAuthority> authorities =
Arrays.stream(findMember.getRole().toString().split(","))
.map(SimpleGrantedAuthority::new)
.collect(Collectors.toList());
UserDetails principal = new org.springframework.security.core.userdetails.User(findMember.getUsername(), "", authorities);
authenticationFromSessionId = new UsernamePasswordAuthenticationToken(principal, "", authorities);
setNewAuthentication((HttpServletResponse) response, authenticationFromSessionId);
SecurityContextHolder.getContext().setAuthentication(authenticationFromSessionId);
}
else { // 둘 다 null 인 경우 에는 로그인이 풀린다.
chain.doFilter(request, response);
}
}
}
chain.doFilter(request, response);
}
1. 난잡한 if문
우선 if 문이 굉장히 난잡합니다. 읽기 쉬운 함수로 빼겠습니다.
if (token != null && isValidJwtFormat(token) && jwtTokenProvider.validateToken(token) {
//...
}
if (authentication != null && authentication.isAuthenticated()) {
//...
}
이렇게 되어 있던 것을
if (isTokenValidate(token)) {
//...
}
if (isAuthenticated(authentication)) {
//...
}
private boolean isAuthenticated(Authentication authentication) {
return authentication != null && authentication.isAuthenticated();
}
private boolean isTokenValidate(String token) {
return token != null && isValidJwtFormat(token) && jwtTokenProvider.validateToken(token);
}
이렇게 뺐습니다. 책에서 말한 것처럼 조건문은 딱 봤을 때 무슨 일을 할지 짐작할 수 있어야 하고, 짐작한 대로 동작해야 합니다. 그렇게 인식되도록 고민해서 이름을 지었습니다.
2. 비대한 if문
if (token != null && isValidJwtFormat(token) && jwtTokenProvider.validateToken(token)) {
// Redis 에 해당 accessToken logout 여부 확인
String isLogout = (String) redisTemplate.opsForValue().get(token);
if (ObjectUtils.isEmpty(isLogout)) {
// 토큰이 유효할 경우 토큰에서 Authentication 객체를 가지고 와서 SecurityContext 에 저장
Authentication getAuthentication = jwtTokenProvider.getAuthentication(token);
HttpSession session = httpRequest.getSession();
session.setAttribute("loginId", getAuthentication.getName());
SecurityContextHolder.getContext().setAuthentication(getAuthentication);
}
}
이 부분은 if 문이 조금 무겁습니다.
if (isTokenValidate(token)) {
// Redis 에 해당 accessToken logout 여부 확인
String isLogout = (String) redisTemplate.opsForValue().get(token);
Authentication validatedAuthentication = validAuthentication(isLogout, token);
setSession(httpRequest, validatedAuthentication);
}
private Authentication validAuthentication(String isLogout, String token) {
if (ObjectUtils.isEmpty(isLogout)) {
// 토큰이 유효할 경우 토큰에서 Authentication 객체를 가지고 와서 SecurityContext 에 저장
Authentication getAuthentication = jwtTokenProvider.getAuthentication(token);
SecurityContextHolder.getContext().setAuthentication(getAuthentication);
return getAuthentication;
}
return null;
}
private void setSession(HttpServletRequest request, Authentication getAuthentication) {
HttpSession session = request.getSession();
session.setAttribute("loginId", getAuthentication.getName());
}
validAuthentication과 setSession 두 함수로 꺼냈습니다.
책에서 함수 인자는 0개가 가장 좋고, 그다음이 1개, 2개이며 3개는 피하라고 했으니 최대한 2개로 줄이려 노력했습니다. 코드만 봐도 어떤 로직인지 짐작되고, 인자에서도 그 동작이 읽히도록 설계했습니다.
Authentication validatedAuthentication = validAuthentication(isLogout, token);
setSession(httpRequest, validatedAuthentication);
isLogout은 캐싱된 토큰이므로, 이 값과 http에서 가져온 토큰을 서로 비교한다는 뜻에서 인자를 두 개로 뒀습니다.
또 request에서 session을 꺼내 Authentication 객체를 저장한다는 뜻에서 setSession이라는 이름으로 뺐습니다.
3. 코드의 중복
if (findMember == null && findUser != null) { // User 객체만 존재할 경우
Collection<? extends GrantedAuthority> authorities =
Arrays.stream(findUser.getRole().toString().split(","))
.map(SimpleGrantedAuthority::new)
.collect(Collectors.toList());
UserDetails principal = new org.springframework.security.core.userdetails.User(findUser.getEmail(), "", authorities);
authenticationFromSessionId = new UsernamePasswordAuthenticationToken(principal, "", authorities);
setNewAuthentication((HttpServletResponse) response, authenticationFromSessionId);
SecurityContextHolder.getContext().setAuthentication(authenticationFromSessionId);
}
else if (findUser == null && findMember != null) { // Member 객체만 존재할 경우
Collection<? extends GrantedAuthority> authorities =
Arrays.stream(findMember.getRole().toString().split(","))
.map(SimpleGrantedAuthority::new)
.collect(Collectors.toList());
UserDetails principal = new org.springframework.security.core.userdetails.User(findMember.getUsername(), "", authorities);
authenticationFromSessionId = new UsernamePasswordAuthenticationToken(principal, "", authorities);
setNewAuthentication((HttpServletResponse) response, authenticationFromSessionId);
SecurityContextHolder.getContext().setAuthentication(authenticationFromSessionId);
}
else if (findMember != null && findUser != null) { // 둘 다 존재하는 경우 우선순위는 Member
Collection<? extends GrantedAuthority> authorities =
Arrays.stream(findMember.getRole().toString().split(","))
.map(SimpleGrantedAuthority::new)
.collect(Collectors.toList());
UserDetails principal = new org.springframework.security.core.userdetails.User(findMember.getUsername(), "", authorities);
authenticationFromSessionId = new UsernamePasswordAuthenticationToken(principal, "", authorities);
setNewAuthentication((HttpServletResponse) response, authenticationFromSessionId);
SecurityContextHolder.getContext().setAuthentication(authenticationFromSessionId);
}
보시면 Collection 객체인 authorities와 principal을 가져오는 부분, Authentication 객체를 만드는 부분, 그걸로 쿠키를 설정하는 부분, SecurityContext에 저장하는 부분까지 세 개의 if 문에서 중복되고 있습니다.
이를 함수로 빼서 중복을 줄였습니다.
if (redisAndSession != null) { // refresh token 이 존재하는 경우 존재하지 않으면 로그인이 풀린다.
Member findMember = memberRepository.findBySessionId(sessionId).orElse(null);
User findUser = userRepository.findBySessionId(sessionId).orElse(null);
if (findMember == null && findUser != null) { // User 객체만 존재할 경우
setAuthenticationWithUser(httpResponse, findUser);
}
else if (findMember != null && findUser == null) { // Member 객체만 존재할 경우
setAuthenticationWithMember(httpResponse, findMember);
}
else if (findMember != null && findUser != null) { // 둘 다 존재하는 경우 우선순위는 Member
setAuthenticationWithMember(httpResponse, findMember);
}
else { // 둘 다 null 인 경우 에는 로그인이 풀린다.
chain.doFilter(request, response);
}
private void setAuthenticationWithMember(HttpServletResponse response, Member findMember) {
Authentication authenticationFromSessionId;
Collection<? extends GrantedAuthority> authorities =
Arrays.stream(findMember.getRole().toString().split(","))
.map(SimpleGrantedAuthority::new)
.collect(Collectors.toList());
UserDetails principal = new org.springframework.security.core.userdetails.User(findMember.getUsername(), "", authorities);
authenticationFromSessionId = new UsernamePasswordAuthenticationToken(principal, "", authorities);
setNewAuthentication(response, authenticationFromSessionId);
SecurityContextHolder.getContext().setAuthentication(authenticationFromSessionId);
}
private void setAuthenticationWithUser(HttpServletResponse response, User findUser) {
Authentication authenticationFromSessionId;
Collection<? extends GrantedAuthority> authorities =
Arrays.stream(findUser.getRole().toString().split(","))
.map(SimpleGrantedAuthority::new)
.collect(Collectors.toList());
UserDetails principal = new org.springframework.security.core.userdetails.User(findUser.getEmail(), "", authorities);
authenticationFromSessionId = new UsernamePasswordAuthenticationToken(principal, "", authorities);
setNewAuthentication(response, authenticationFromSessionId);
SecurityContextHolder.getContext().setAuthentication(authenticationFromSessionId);
}
Member 객체와 User 객체로 각각 Authentication을 만드는 것이라 함수를 둘로 나눴습니다.
4. 최종본
@Override
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {
// 1. Request Header 에서 JWT 토큰 추출
String token = resolveToken((HttpServletRequest) request);
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
HttpServletRequest httpRequest = (HttpServletRequest) request;
HttpServletResponse httpResponse = (HttpServletResponse) response;
// 2. validateToken 으로 토큰 유효성 검사
if (isTokenValidate(token)) {
// Redis 에 해당 accessToken logout 여부 확인
String isLogout = (String) redisTemplate.opsForValue().get(token);
Authentication validatedAuthentication = validAuthentication(isLogout, token);
setSession(httpRequest, validatedAuthentication);
}
else if (isAuthenticated(authentication)) { // 3. access token 이 만료된 상황
// token 은 만료 되었으나 인증이 되어있는 사용자 = 아직 페이지를 안벗어났지만 token 의 유효시간은 끝난 사용자
UserResponseDto tokenInfo = (UserResponseDto) redisTemplate.opsForValue().get("RT:" + authentication.getName());
if (tokenInfo != null) {
setNewAuthentication((HttpServletResponse) response, authentication);
SecurityContextHolder.getContext().setAuthentication(authentication);
}
}
else { // 4. access token 만료되고 Authentication 객체도 null 인 상황
String sessionId = getSessionId(httpRequest);
RedisAndSession redisAndSession = null;
if (sessionId != null) {
redisAndSession = redisTokenRepository.findBySessionId(sessionId).orElse(null);
}
if (redisAndSession != null) { // refresh token 이 존재하는 경우 존재하지 않으면 로그인이 풀린다.
Member findMember = memberRepository.findBySessionId(sessionId).orElse(null);
User findUser = userRepository.findBySessionId(sessionId).orElse(null);
if (findMember == null && findUser != null) { // User 객체만 존재할 경우
setAuthenticationWithUser(httpResponse, findUser);
}
else if (findMember != null && findUser == null) { // Member 객체만 존재할 경우
setAuthenticationWithMember(httpResponse, findMember);
}
else if (findMember != null && findUser != null) { // 둘 다 존재하는 경우 우선순위는 Member
setAuthenticationWithMember(httpResponse, findMember);
}
else { // 둘 다 null 인 경우 에는 로그인이 풀린다.
chain.doFilter(request, response);
}
}
}
chain.doFilter(request, response);
}
private boolean isAuthenticated(Authentication authentication) {
return authentication != null && authentication.isAuthenticated();
}
private boolean isTokenValidate(String token) {
return token != null && isValidJwtFormat(token) && jwtTokenProvider.validateToken(token);
}
private void setAuthenticationWithMember(HttpServletResponse response, Member findMember) {
Authentication authenticationFromSessionId;
Collection<? extends GrantedAuthority> authorities =
Arrays.stream(findMember.getRole().toString().split(","))
.map(SimpleGrantedAuthority::new)
.collect(Collectors.toList());
UserDetails principal = new org.springframework.security.core.userdetails.User(findMember.getUsername(), "", authorities);
authenticationFromSessionId = new UsernamePasswordAuthenticationToken(principal, "", authorities);
setNewAuthentication(response, authenticationFromSessionId);
SecurityContextHolder.getContext().setAuthentication(authenticationFromSessionId);
}
private void setAuthenticationWithUser(HttpServletResponse response, User findUser) {
Authentication authenticationFromSessionId;
Collection<? extends GrantedAuthority> authorities =
Arrays.stream(findUser.getRole().toString().split(","))
.map(SimpleGrantedAuthority::new)
.collect(Collectors.toList());
UserDetails principal = new org.springframework.security.core.userdetails.User(findUser.getEmail(), "", authorities);
authenticationFromSessionId = new UsernamePasswordAuthenticationToken(principal, "", authorities);
setNewAuthentication(response, authenticationFromSessionId);
SecurityContextHolder.getContext().setAuthentication(authenticationFromSessionId);
}
private Authentication validAuthentication(String isLogout, String token) {
if (ObjectUtils.isEmpty(isLogout)) {
// 토큰이 유효할 경우 토큰에서 Authentication 객체를 가지고 와서 SecurityContext 에 저장
Authentication getAuthentication = jwtTokenProvider.getAuthentication(token);
SecurityContextHolder.getContext().setAuthentication(getAuthentication);
return getAuthentication;
}
return null;
}
private void setSession(HttpServletRequest request, Authentication getAuthentication) {
HttpSession session = request.getSession();
session.setAttribute("loginId", getAuthentication.getName());
}
마치며
확실히 리팩토링하고 나니 전보다 읽기 쉬워졌습니다. 아직 책을 정독하는 중이라, 다른 리팩토링 기법을 배우면 다시 한번 손을 대 볼 생각입니다.
긴 글 읽어 주셔서 감사합니다. 오늘도 즐거운 하루 되세요.