728x90

스테이트 패턴 적용기

모든 코드는 깃허브에 있음을 알린다.

현재 패스트캠퍼스의 디자인 패턴 강의를 수강도 하고있고, 동시에 디자인 패턴에 대한 책도 한권 읽었었다.

그러니까 메인 메소드만 있는곳에서 디자인패턴을 적용하여 연습을 조금 했었다.

그리고 프로젝트에 도입을 했었는데 약간 2% 부족한 패턴이 탄생했었다.

지금에서의 완벽한 디자인패턴

프로젝트를 통해서 유저 포인트를 조건에 따라 변경해주는 로직이 있었다.

스크린샷 2021-10-06 오후 11 19 38

자... 예전의 나는 보잘 것 없었다.

ㅋㅋㅋㅋ 이게 그때 당시의 나에겐 최선이었을지도??

아무튼 각설하고 저 로직을 이제 바꿔보려고 한다.

해당로직의 bean이라는 객체는 단지 저 포인트와 해당 유저의 아이디만 가지고 있는 일종의 DTO이고,

네이밍마저 가독성을 해쳤다.

이제부터 리팩토링 과정을 적어보려고 한다.

리팩토링 과정

스크린샷 2021-10-06 오후 11 31 24

어설프지만 클래스 다이어그램을 그려보았다.

일단 PointState가 최종적으로 저 4개의 랭크들을 주입받아 사용하기 때문에 화살표를 그어주지는 않았다.

예전 방식으로는 저 클래스 하나하나마다 빈을 등록해주고 switch를 배제한대신 if문에 return방식으로 구현했다. 물론 뒷단으로 로직을 빼긴했지만 뒤지다보면 어찌됐든 나오게 되어있는 방식이었다...

일단 어떻게 구현했는지 하나하나 보도록하자

일단 기본 틀이되는 Rank부터 살펴보자

Rank.java

public interface Rank {
    int getRankEnum();
    int giveUserPoint();
}

랭크를 확인할 수 있는 로직과, point를 세팅해주는 로직이 있다.

RankEnum.java

@Getter
@NoArgsConstructor
@AllArgsConstructor
public enum RankEnum {
    FIRST(1),
    SECOND(2),
    THIRD(3),
    ANOTHER(-1);

    private int rank;
}

랭크별 enum을 관리하기로 생각해서 따로 빼주었다.

public class First implements Rank {

    @Override
    public int getRankEnum() {
        return RankEnum.FIRST.getRank();
    }

    @Override
    public int giveUserPoint() {
        return 3000;
    }

}

나머지 클래스들은 안에 반환값만 다르기 때문에 First만 가져오겠다.

PointState.java

public class PointState {
    private final Map<Integer, Rank> pointStateMap = new HashMap<>();

    public PointState(List<Rank> ranks) {
        ranks.forEach(rank -> {
            pointStateMap.put(rank.getRankEnum(), rank);
        });
    }

    public Rank getPointStateMap(int rank) {
        return pointStateMap.get(rank);
    }

}

구현체인 PointState이다.

@Configuration
public class RankConfig {

    @Bean
    public PointState pointState() {
        return new PointState(Arrays.asList(new First(), new Second(), new Third(), new Another()));
    }

}

나는 이렇게 Config 클래스에서 런타임때에 초기화를 해주는데 생성자의 형식에 맞게
Rank에 해당하는 4가지 구현체를 넣어주었다.
이제 int형의 랭크 등급에 따라 추가할 포인트값을 받을 수 있게 되었다.

이제는 이게 정상적으로 동작하는지 확인을 해보기 위해 테스트코드를 먼저 짜도록 해보겠다.

테스트코드 작성

PointState.java

@SpringBootTest(classes = RankConfig.class)
class PointStateTest {

    @Autowired
    PointState pointState;

    @Test
    void test() {
        Rank rank = pointState.getPointStateMap(RankEnum.FIRST.getRank());

        assertThat(rank.getRankEnum()).isEqualTo(RankEnum.FIRST.getRank());
        assertThat(rank.giveUserPoint()).isEqualTo(3000);
    }

}

Rank설정을 해준 RankConfig를 가져온 후에 적용시켰다.

그렇지 않으면 마이바티스가 섞여있기 때문에 지금 에러가 발생한다 😱

스크린샷 2021-10-06 오후 11 44 16

정상적으로 통과하는것을 볼 수 있다.

이제 switch 로직을 리팩토링 할 생각이다.

스크린샷 2021-10-06 오후 11 45 29

이렇게 바꾸었고 이제 조건에 분기하여 포인트 값을 할당하지 않고 바로 작업할 수 있게 되었다!!!

정리

스크린샷 2021-10-06 오후 11 19 38

이랬던 로직이

스크린샷 2021-10-06 오후 11 45 29

이렇게 바뀐거 보고 디자인패턴 정말 좋다고 느낀다.

하나하나 배우면서 계속 적용해보고 싶어지는게 당연하달까..

아무튼 좋은 경험이었고 프로젝트 리팩토링이 잘되어가는것 같다 👍

728x90

'Java' 카테고리의 다른 글

일급 컬렉션  (0) 2022.08.09
변수  (0) 2022.08.07
프로젝트 리팩토링  (0) 2022.08.07
디자인 패턴 - Bridge Pattern  (0) 2022.08.07
728x90

Querydsl

지금 현재 리팩토링을 진행중인 프로젝트가 있는데,

내가 스터디를 했던 JPA를 적용시켜 Mybatis에서 JPA로 이전작업을 하는 중에

생각했던것 보다 QueryDSL을 더 많이 사용하는 것 같다.

많은 분들이 올리신 JPA와 같이 쓰는 QueryDSL은 별도의 support클래스를 만들어서

할당하는 방식으로 사용했다.

나는 그런데 조금 다르게 설정을 했다.

커스텀 인터페이스 구성

공식문서의 4.6.1챕터 에서 보면 Querydsl을 같이 사용하려면

해당 엔티티 Repository인터페이스에 다중상속으로 RepositorySupport 인터페이스를 상속해주고

RepositorySupportImpl에서 Support 인터페이스를 구현해주었다.

이렇게 말이다.

@Repository
public interface UserRepository extends JpaRepository<User, Long>, UserRepositorySupport {

}
public interface UserRepositorySupport {
}

public class UserRepositorySupportImpl implements UserRepositorySupport {

}

이렇게 인터페이스를 상속받아주게 되면 기본이 Impl인데 상속받아주게 되면

커스텀 Repository가 등록이 된다.

그리고 JpaRepository를 구현하고 있는 SimpleJpaRepository에는 @Repository 어노테이션이

붙어있으므로 자연스럽게 전부 어노테이션은 달지 않아줘도 된다.

그리고 이제 저 SupportImpl 에는 Querydsl 세팅하면서 등록해준 빈을 생성자 주입으로 할당받아

사용하면 되겠다.

Querydsl 문법

일단 사실 부딪히면서 생긴 에로사항들이 꽤 나왔다.

음 단순한 이론책이 아니라, 실습을 해서 몸으로 깨달아야 하는 지식은

책보면서 그냥 이론만 학습해서는 상당히 휘발성인 지식이 되어버린다.

몸으로 깨달으면서 이렇게 포스팅으로 정리까지 한다? 아주 👍

조금 찾아봤던 문법들을 정리해보겠다.

add, subtract

일단 update 과정에서 조회수를 +1 해야하는 로직을 예로 들어보겠다.

UPDATE board SET read_cnt = read_cnt + 1 where board_no = ?

이렇게 될 것이다.

이걸 근데 어떻게 해줄까 고민을 했었다.

그냥 수식에 + 1을 해주려고 로직에 넣었더니 에러가 발생했다. 😭

jpaQueryFactory.update(board)
            .set(board.readCnt, board.readCnt.add(1)) // + 1의 경우
            .set(board.readCnt, board.readCnt.subtract(1)) // - 1 의 경우
            .where(board.id.eq(id))
            .execute();

두가지를 동시에 적었지만 하나만 사용했었다.

상당히 간단했던 에로사항인데 +1을 해야한다는 생각만 있어서 깊어지게 됐던것 같다.

이건 너무 간단하고 그리고 단순한 add()subtract() 로 해결했다.

진행했던 스터디분들에게도 여쭤봤었다.

감사합니다 ㅠㅠ 🙏

스크린샷 2021-09-17 오후 12 54 34

너무 간단해서 이 방식은 여기서 끝내도록 하겠다.

like, contains

검색기능을 사용하기 위해서 sql문에선 like를 사용했다.

여기서부터 이제 문법이란 문법은 다 **Expression 에 들어있는 것을 확인했다.

like, contains, startswith, endswith 전부 StringExpression

포함된 메소드들 이었다.

발견했던 것은 like는 해당하는 문자열이랑 같은 것을 뽑아주었고,

contains는 단어의 의미대로 앞뒤로 %를 붙여준 것과 같아서 나는 contains를 사용했다.

startswith는 특정 문자로 시작하는 것,

endswith는 특정문자로 끝나는 쿼리문을 만들어준다. 👍

public class NewBookRepositorySupportImpl implements NewBookReposirotySupport {
  public List<NewBook> selectGenre(String nbGenre) {
          return jpaQueryFactory.selectFrom(newBook)
              .where(newBook.nbGenre.contains(nbGenre))
              .fetch();
  }
}

max

최고 수치값을 쓸때는 단순하게 max를 써주면 되는데 왜 이 max 를 여기에 포함시켰냐 라고 묻는다면

내 경우에는 맥스 수치값을 뽑아내는게 아니라 맥스에 해당하는 컬럼을 조회했어야 했다.

select * from newbook where nb_scount = (select max(nb_scount) from newbook);

요런 형식의 sql문이었다.

자세히 들여다보니 maxNumberExpression에 있었다.

근데 저 괄호에 다시 select를 하려면 JPAExpressions 에서 JPQLQuery 인스턴스를 제공해주는데

그 클래스에서 쿼리를 새로 만들어주어야 했다.

아래는 클래스의 일부분이다.

스크린샷 2021-09-17 오후 1 09 01

이런식으로 쿼리를 만들어 줄 수 있었기 때문에

@Override
public NewBook selectBestSeller() {
    return jpaQueryFactory.selectFrom(newBook)
        .where(newBook.nbScount
            .eq(JPAExpressions.select(newBook.nbScount.max())
                .from(newBook)))
        .fetchOne();
}

위의 소스처럼 eq()안에 JPQL쿼리를 넣어서 만들어 주었다.

일단 이런 문법 자체를 잘 파악해서 필요한걸 매칭을 재빠르게 해주려면 필요한 것은

DB SQL문을 잘 알고 있어야한다.

정리

아직 더 큰 이슈사항은 만나지 않아서 여기까지 정리하도록 하겠다.

한가지 의문점은 원래 QueryDSL이 많아지는가? 에 대한 개인적인 생각이다.

기초적인 CRUD와 쿼리메소드가 아무리 강력하다고 한들 상세하게 조회하는 쿼리들에 대해선

QueryDSL이 빠질 수는 없으니까 말이다.

현재 단위테스트 진행은 이런 자잘한 이슈들을 겪으면서 한 테이블씩 천천히 진도를 나가는 중이다.

더 나아가서 서비스, 컨트롤러 등등 단위 테스트를 넣을 예정이며 통합테스트도 고려해야겠다.

728x90

'Java' 카테고리의 다른 글

변수  (0) 2022.08.07
상태 패턴 적용  (0) 2022.08.07
디자인 패턴 - Bridge Pattern  (0) 2022.08.07
Oauth 이슈  (0) 2022.08.07
728x90

모든 코드는 깃허브에 있다.

Bridge 패턴이란?

브릿지 패턴은 구현부에서 추상층을 분리하여 각자 독립적으로 변형 및 확장이 가능하도록 만드는 패턴이다.

이렇게 보면 너무 추상적인 문장이었다.

기능과 구현에 대해 두개의 별도 클래스로 구현한다.

Bridge의 뜻은 다리이다. 다리가 떨어진 장소를 연결하듯이 Bridge 패턴도 두 장소를 연결하는 역할을 한다고 보면된다.
이것이 Bridge 패턴의 핵심이다.
다리역할을 해주는 두 곳은 기능 클래스구현 클래스 이다.

두가지 경우로 나눠서 생각해보자.

  • 새로운 기능을 추가하고 싶은 경우
  • 새로운 구현을 추가하고 싶은 경우

어떤 새로운 클래스 ex가 있다고 예를 들자.

이 ex 클래스에 새로운 기능(=새로운 메소드)를 추가하고 싶을 때 어떻게 할것인가?

여기서 ex를 상속한 하위 클래스에 exChild 클래스를 만든다.
여기서 작은 클래스 계층이 생성된다.

ex ← exChild

이건 기능을 추가하기 위해 만들어진 계층이다.
이렇게 되면, 상위 클래스는 기본 기능은 가지고 있고, 자식 클래스에서는 새로운 기능을 추가할 수가 있게된다.

이러한 클래스 계층을 기능 클래스 계층 이라고 한다.

추상클래스는 메소드들을 추상 메소드로 선언하고 인터페이스를 정한다.
그리고 하위 클래스에서는 그 추상 메소드를 실제로 구현하는 역할을 한다.

이와같이 상위 클래스와 하위 클래스의 역할 분담에 의해 부품으로서의 가치가 높은 클래스를 만들 수 있는데, 여기서 두번째의 경우가 나온다.

상위 클래스는 추상 메소드에 의해 인터페이스를 규정
하위 클래스는 구체적인 메소드에 의해 그 인터페이스를 구현

이러한 클래스 계층을 구현 클래스 계층 이라고 한다.

새로운 구현을 만들기 위해선 추상클래스의 자식 클래스를 만들어 추상 메소드를 Override 하여 구현하면 된다.

기능 추가 or 구현 추가 선택

클래스 계층이 하나만 있다면 기능 클래스와 구현 클래스가 하나의 계층구조 안에서 섞이기 마련이다.

그래서 기능과 구현의 계층을 독립된 클래스 계층으로 분리하고 그 분리한 두 개의 클래스를 다리를 놓아 연결해주는것이 Bridge 패턴이다.

클래스 다이어그램

image

클래스 다이어그램으로 보면 다음과 같다.

  • Abstraction
    • 추상화 개념의 부모 클래스, 구현에 대한 참조자를 관리한다.
  • RefinedAbstraction
    • 추상화 개념의 확장된 기능을 정의한다.
  • Implementor
    • 인터페이스로써 구현 클래스에 대한 선언을 제공함.
    • ImplementorAbstraction의 메소드 이름은 다를 수가 있다.
  • ConcreteImplementor
    • Implementor에 선언된 기능을 구현하는 콘크리트 클래스이다.

클래스 다이어그램을 가만히 보고있으면 RefinedAbstraction 이 여러개라면,

추상 팩토리메소드가 구현이 될것 같은 느낌이다.

결국 Operation 인터페이스에 따라 또 정의된 Abstraction을 입맛에 맞게 끼워넣어

해당 기능을 사용하는 구조를 만들 수가 있다고 본다.

저 브릿지라는 개념을 생성하고 합성하는 것이 추상 팩토리 메소드 패턴과 관련이 있는것 같고

어댑터 패턴과도 관련이 있는것 같다.

아래는 예전에 구현했던 예제이고

최신으로 구현한 것은 깃허브에 있다.

Display.java

//기능 클래스 계층
public class Display {
    //이 필드가 두 클래스 계층의 다리역할을 한다.
    private DisplayImpl impl;

    public Display(DisplayImpl impl){
        this.impl = impl;
    }

    public void open(){
        impl.rawOpen();
    }

    public void print(){
        impl.rawPrint();
    }

    public void close(){
        impl.rawClose();
    }

    public final void display(){
        open();
        print();
        close();
    }

}

이 클래스는 기능 클래스 최상에 있는 클래스이다. DisplayImpl 필드는 Display 클래스의 구현을 담당하는 인스턴스이다.

이 Impl 필드가 Bridge가 되는것이다.

CountDisplay.java

//기능클래스
public class CountDisplay extends Display{

    public CountDisplay(DisplayImpl impl) {
        super(impl);
    }

    public void multiDisplay(int times){
        open();
        IntStream.range(0, times).forEach(i -> print()); //times 회수만큼 반복하여 표시
        close();
    }
}

Display에 times의 횟수만큼 표시하라는 기능을 추가한 기능 클래스이다.

DisplayImpl.java

//구현 클래스
public abstract class DisplayImpl {
    public abstract void rawOpen();
    public abstract void rawPrint();
    public abstract void rawClose();
}

추상클래스이며 처음,중간,끝에 해당하는 open,print,close 에 대응하여 처리된다.

StringDisplayImpl.java

public class StringDisplayImpl extends DisplayImpl{

    private final String string;
    private final int width;

    public StringDisplayImpl(String string){
        this.string = string;
        this.width = string.getBytes().length;
    }

    @Override
    public void rawOpen() {
        printLine();
    }

    @Override
    public void rawPrint() {
        System.out.println("|" + string + "|");
    }

    @Override
    public void rawClose() {
        printLine();
    }

    private void printLine() {
        System.out.print("+");
        //for문 stream형식 한번 sout 실행될때 '-' 출력
        IntStream.range(0, width).mapToObj(i -> "-").forEach(System.out::print);
        System.out.println("+");
    }
}

DisplayImpl을 상속받은 구현 클래스이다. 문자열을 표시하는 클래스 임과 동시에 DisplayImpl의 하위클래스로써 Override한 rawOpen, rawPrint, rawClose를 구현하였다.

Main.java

public class Main {
    public static void main(String[] args) {
        Display d1 = new Display(new StringDisplayImpl("Hello, Korea"));
        Display d2 = new Display(new StringDisplayImpl("Hello, world"));

        CountDisplay d3 = new CountDisplay(new StringDisplayImpl("Hello, universe"));

        d1.display();
        d2.display();
        d3.display();

        d3.multiDisplay(5);
    }
}

Main클래스에서는 전체 클래스를 조합하여 문자열을 표시한다.
d1,d2,d3 전부 Display의 일종이므로 display 메소드를 호출할 수 있으며, d3은 CountDisplay에 구현된 multiDisplay메소드를 호출 가능하다. Display 인스턴스 안에 StringDisplayImpl 클래스의 인스턴스가 구현을 담당한다.

Bridge 패턴의 핵심

이 패턴의 핵심은 기능 클래스구현 클래스를 분리하는 것이다.
이 두 클래스 계층을 분리해두면 각각의 클래스 계층을 독립적으로 확장이 가능하다.

728x90

'Java' 카테고리의 다른 글

상태 패턴 적용  (0) 2022.08.07
프로젝트 리팩토링  (0) 2022.08.07
Oauth 이슈  (0) 2022.08.07
SOLID 원칙  (0) 2022.08.06
728x90

진행중인 사이드 프로젝트에서 생겼던 이슈를 정리합니다.

모든 코드는 깃허브에 있습니다.

스터디 위키 정리중에창훈님께서 스프링 시큐리티에 대한 정보를 공유해주셨습니다.

그안에 포함되어있는 스프링 시큐리티 Oauth2에 대해서 포스팅을 해볼 것입니다.

Oauth 란?

OAuth는 인터넷 사용자들이 비밀번호를 제공하지 않고 다른 웹사이트 상의 자신들의 정보에 대해 웹사이트나 애플리케이션의 접근 권한을 부여할 수 있는 공통적인 수단으로서 사용되는, 접근 위임을 위한 개방형 표준입니다. 이 매커니즘은 여러 기업들에 의해 사용되는데, 저희 서비스로 생각한다면 구글, 카카오, 애플, 깃허브, 페이스북 들이 있으며 사용자들이 타사 애플리케이션이나 웹사이트의 계정에 관한 정보를 공유할 수 있게 허용해줍니다.

OAuth인증은 소비자와 서비스 제공자 사이에서 일어나는데 이 인증 과정은 다음과 같습니다.

image

여기서 소비자는 내 서비스임을 명시하고

서비스 제공자는 구글 로 가정하겠습니다.

  1. 소비자가 서비스제공자에게 요청토큰을 요청합니다.
  2. 서비스제공자가 소비자에게 요청토큰을 발급해준다.
  3. 소비자가 사용자를 서비스제공자로 이동시킨다. 여기서 사용자 인증이 수행된다.
  4. 서비스제공자가 사용자를 소비자로 이동시킨다.
  5. 소비자가 접근토큰을 요청한다.
  6. 서비스제공자가 접근토큰을 발급한다.
  7. 발급된 접근토큰을 이용하여 소비자에서 사용자 정보에 접근한다.

인증

이 Oauth에 대한 인증은 기본적으로 Spring Security 내에 명시가 되어있습니다.

바로 CommonOAuth2Provider 라는 Enum 클래스인데요.

이 클래스를 사용해서 Bean을 등록하는것도 가능합니다.

public enum CommonOAuth2Provider {
GOOGLE {

        @Override
        public Builder getBuilder(String registrationId) {
            ClientRegistration.Builder builder = getBuilder(registrationId,
                    ClientAuthenticationMethod.CLIENT_SECRET_BASIC, DEFAULT_REDIRECT_URL);
            builder.scope("openid", "profile", "email");
            builder.authorizationUri("https://accounts.google.com/o/oauth2/v2/auth");
            builder.tokenUri("https://www.googleapis.com/oauth2/v4/token");
            builder.jwkSetUri("https://www.googleapis.com/oauth2/v3/certs");
            builder.issuerUri("https://accounts.google.com");
            builder.userInfoUri("https://www.googleapis.com/oauth2/v3/userinfo");
            builder.userNameAttributeName(IdTokenClaimNames.SUB);
            builder.clientName("Google");
            return builder;
        }

    },

    //....
}

하지만 제가 구현한 방식은 이 방법이 아니라 application.yaml에서 설정을 해주었습니다.

security:
    oauth2:
      client:
        registration:
          google:
            client-id: ENC(6/Ponyj/US/TvdVEZJfmcsPmvaXwKaeCWyudJyZDkNoTnloH+R4r0dR8MFFrA90SkJag3l4b67WQrIqR9Oz8Y6IK45M9ypW0DMxUGsOFc3rFijwDrAhK2Q==)
            client-secret: ENC(FoXXYeauW6EKov8iMfbE57X8cV9jEf4WSQ+FFmjnNIakgPv768fkng==)
            scope: profile, email

설정을 해주면 Oauth2Configurer를 통해 구현되게 되는데요.

ENC()jasypt 라는 보안으로 인한 암호화 모듈인데요 다른 글에서 포스팅을 하도록 하겠습니다. 😅

OAuth2LoginAuthenticationFilter에는 Default URI인 /login/oauth2/code/* 가 들어있습니다.

그래서 위의 yaml에서 redirect_uri를 잡아주지 않는다면 이 default를 통해 redirect 하게 됩니다.

이슈 사항

그래서 구글 api에도 /login/oauth2/code/google 로 설정해주었습니다.

저희 cors원칙을 설정해주기 위해서 리다이렉션 URI를 /api/v1/login/oauth2/google 과 같이

추가해주기 위해 yaml google에는 아래를 추가해주었습니다.

redirect-uri: '{baseUrl}/api/v1/login/oauth2/{registrationId}'

SecurityConfig의 일부입니다.

public class SecurityConfig {
@Override
    protected void configure(final HttpSecurity http) throws Exception {
        http
                .csrf().disable()
                .httpBasic().disable()

                // =====일반 로그인 처리 로직 진행=====


                // Oauth 로그인 수행
                .oauth2Login(login ->
                        login
                                .userInfoEndpoint()
                                .userService(customOauth2UserService)
                )
    }
}

이렇게하고 구글 api설정도 바꿔주면 진행이 잘될거라고 생각했습니다.

디버깅을 통한 login 이라는 Oauth2LoginConfigurer의 일부입니다.

스크린샷 2021-09-02 오후 9 50 22
{: text-center}

스크린샷 2021-09-02 오후 10 10 41

login을 처리하는 url은 바뀌지않고 OAuth2LoginAuthenticationFilter의 default 값이 들어있는 것을 확인하였습니다.

위 코드에서 login 객체에 보면 프로세싱 url은 변함없이 기본값을 할당하고 있었습니다.

스크린샷 2021-09-02 오후 10 11 03

이유는 할당한 채로 제가 커스텀하여 할당하지 않았기 때문에 저 상태로 기본값이 저장되어 있던 것입니다. 😱

해결은

.oauth2Login(login -> 
        login.loginProcessingUrl("/api/v1/login/oauth2/*")
                                .userInfoEndpoint()
                                .userService(customOauth2UserService)
)

이렇게 프로세싱 url을 정해주고 다시 실행하니 정상적으로 실행 되는 것을 확인하였습니다. 👍

이렇게 해서 모든 Oauth 로그인이 돌아가는 형식으로 구성되어 있습니다.

스크린샷 2021-09-02 오후 10 07 56

그렇게 하여 ClientResistration에는 yaml에서 설정했던 id, secret, redirect-uri가 여기서 할당된것을 확인할 수 있습니다.

728x90

'Java' 카테고리의 다른 글

프로젝트 리팩토링  (0) 2022.08.07
디자인 패턴 - Bridge Pattern  (0) 2022.08.07
SOLID 원칙  (0) 2022.08.06
연산자  (0) 2022.08.06
728x90

📌 SOLID 원칙

객체 지향적으로 설계하는데에 있어 기본이 되는 SOLID원칙에 대해서 알아보자.
SOLID는 각 원칙의 첫번째 글자를 따서 만든 것이다.

  • 단일 책임 원칙 (Single responsibility principle; SRP)
  • 개방-폐쇄 원칙 (Open-closed principle; OCP)
  • 리스코프 치환 원칙 (Liskov substitution principle; LSP)
  • 인터페이스 분리 원칙 (Interface segregation principle; ISP)
  • 의존 역전 원칙 (Dependency inversion principle; DIP)

이 원칙들이 서로 다른 내용이라 하기에는 밀접하게 연관되어 있으므로 한꺼번에 같이 이해해야 할 것이다.

📌 단일 책임 원칙

객체 지향은 책임을 객체에게 할당하는데에 있다. 객체로 존재하게 하는 이유가 책임인데

이 원칙은 원칙 이름에서도 알 수 있듯 책임과 관련되어 있다.

📢 클래스는 단 한 개의 책임을 가져야 한다.

하나의 클래스가 여러 책임을 갖게되면 책임마다의 이유가 발생하기 때문에 클래스 변경이 잦아질 수 있다.

그래서 한개의 이유로만 클래스가 변경되려면 하나의 책임만을 가져야 한다.

지금 진행하고있는 TDD강좌를 들으면서 SOLID원칙 그리고 디자인 패턴에 대해 많이 학습하는것 같다.

1-2-3 과정으로 진행되는 과정이 예를 들어 있다고 한다면

public class Process {
    public void one() {
        /// 1...
        String test = two();
        three(test);
    }


    public void two() {
        /// 2...
    }

    public void three(String test) {
        /// 3...
    }
}

이렇게 작업을 하게되면, 연쇄적으로 코드 수정을 해야할 수 밖에 없다.

이것은 코드를 분리만했지 절차지향과 다를게 없다.

여기서 만약 String타입의 인자가 아니라 다른타입으로 오게되면 twothree 모두 바꿔야한다.

극단적인 예시이지만, 같은 작업을 수행하는 것이라면 괜찮지만
데이터를 불러오고, 가공하고, 보여준다 라면 얘기는 다르다.

그러면 클래스마다 1개씩 잘라서 분리를 해주어야 한다.

그래서 최종적으로 동작하는 클래스에서 조립하는 느낌으로 구현을 한다고 생각하면 이해가 쉬울 듯 하다.

📌 개방-폐쇄 원칙

개방, 폐쇄 말만들으면 열고 닫는것.

확장에는 열려 있어야 하고, 변경에는 닫혀있어야 한다.

말이 이렇게만 보면 어렵다.

기능을 변경하거나 확장은 할수 있되, 그 기능을 사용하는 코드는 수정을 하지 않아야 된다.

이부분은 추상화와 관련이 깊은것 같다. 사용하는 코드를 수정하지 않아야한다 라는 원칙이기 때문에

인터페이스 또는 추상클래스를 사용했을 시에 구현체에 따라 다르게 동작할 수 있게 만듬으로써

변경 확장이 용이하며, 코드 수정은 하지 않게된다.

개방 폐쇄 원칙이 깨질때의 증상 1 - 다운 캐스팅

이것은 다운 캐스팅을 할 경우에 그런데, instanceof 로 타입 다운을 시켜 검증을 하면

발생하는 것인데

내 경우에는 볼링게임 여기서 사용했었다.

객체마다 다르게 동작할 수 있는 무언가가 있다면 그 메소드를 추상화하여 동작하게 만들어야한다.

개방 폐쇄 원칙이 깨질때의 증상 2 - if-else

public void draw() {
    if (index == 1) {
        // 1조건
    }else if (index == 2) {
        // 2조건
    }
    ...
}

위 코드처럼 조건이 하나씩 늘게되면 else if로 늘려서 추가할 경우이다.

draw에서 그냥 메소드를 통일하고

Pattern이라는 인터페이스를 생성하고 방식에따라 다른 로직을 구현하고 메서드는 통일시키는 식으로

추가를 해주자.

개방 폐쇄 원칙은 유연함에 대한 것

이 원칙은 변화가 예상되는 것들을 추상화해서 유연함을 얻도록 해줬다.

그러니까 추상화를 하지 않거나 아직 개념이 부족해 못한다면

개방-폐쇄 원칙을 지킬 수 없게 되어 기능 변경이나 확장을 어렵게 만든다는 것이다.

내가 퇴사했던 회사에서 딱 이렇게 개발을 했었다. 오히려 이게 쉽다면서... 난 아니었는데 😥

각설하고, 코드의 변화 요구가 발생하면 이 구현부를 추상화해서 지금의 이 원칙에 맞게 수정할 수 있는지

항상 생각하면서 개발해야겠다.

📌 리스코프 치환 원칙

윗부분의 개방폐쇄에선 추상화 그리고 다형성을 이용하여 구현되었다.

이번에 알아보는 이 리스코프 치환 원칙은 개방 폐쇄를 조력해주는(?) 다형성에 관한 원칙이다.

상위 타입의 객체를 하위 타입의 객체로 치환하여도

상위 타입을 사용하는 프로그램은 정상적으로 동작해야 한다.

이거는 예제를 따로 개발자가 반드시 정복해야할 객체지향과 디자인 패턴에서 인용하였다.

부모 클래스와 자식 클래스가 있다고 가정. 특정한 메서드는 상위 타입인 부모클래스를 이용할 것이다.

public void someMethod(SuperClass sc) {
    sc.someMethod();
}

someMethod는 부모타입의 객체를 사용하고 있는데, 이 메소드에 하위타입 객체를 전달해도 someMethod

정상적으로 동작해야 한다. 이것이 바로 리스코프 치환 원칙이다.

중요❗❗❗ 이 원칙이 제대로 지켜지지 않는다면 개방-폐쇄역시 지켜지지 않는다.

개념적으로 상속 관계에 있는 두개의 클래스가 있더라도 막상 구현했을때는 상속 관계가 아닐 수도 있는 것들이 있다.

이럴 때는 상속이 아니라 각자 다른 타입으로 매칭을 시켜줘야한다.

📌 인터페이스 분리 원칙

인터페이스는 그 인터페이스를 사용하는 클라이언트를 기준으로 분리해야 한다.

이 원칙또한 앞에서와 연관이 있는데 결국 용도에 맞게 인터페이스를 분리하는 것은

단일 책임 원칙과 연결이 된다. 하나의 타입에 여러 기능이 섞일 경우

한가지의 기능 변화로 인해 다른 기능이 영향을 받을 가능성이 높아진다.

그래서 인터페이스를 분리한다면 한 기능에 대한 변경의 리스크를 줄일 수 있게 된다.

그리고 인터페이스의 분리가 잘되어 있다면 즉, 단일 책임 원칙이 잘 지켜질때

구현클래스들의 재 사용성이 높아질 수 있기 때문에

결국 이 원칙은 인터페이스와 구현 클래스의 재사용성을 높인다는 효과를 가지게 된다.

원칙은 클라이언트에 대한 것

인터페이스 분리 원칙은 클라이언트 입장에서 인터페이스를 분리하란 원칙이다.

클라이언트가 사용하는 기능들을 중심으로 인터페이스를 분리하여 클라이언트에서 발생하는 인터페이스

변경의 여파가 다른 클라이언트에 미치는 것을 최소화 할 수가 있다.

📌 의존 역전 원칙

고수준 모듈은 저수준 모듈의 구현에 의존해서는 안된다.

저수준 모듈이 고수준 모듈에서 정의한 추상 타입에 의존해야 한다.

고수준 모듈은 저수준 모듈을 조합해서 한 모듈을 만들어 놓은 형태이고

저수준 모듈은 도메인단위로 분리했을 때 상세한 행동 하나하나를 정의한 것을 말한다.

고수준 모듈이 저수준 모듈에 의존할 떄의 문제

내가 생각하는 이 SOLID 원칙. 어떤 법칙을 읽던간에 무조건 인터페이스가 생각나는게 당연한걸까❓

이것도 내가 생각하기엔 추상화가 답인것 같다.

구현하는 비즈니스 클래스가 여러개가 있다고 한다면 개방폐쇄의 예시처럼 조건일때 다른 클래스 로직을 불러와

실행하는 것이 될것이다.

이런 방식이 프로그램을 변경하는데 너무 어렵게 만든다. 이 저수준 모듈이 변경됨에 따라

고수준은 변경이 되지 않게끔 하려면 나오는 원칙이 의존 역전 원칙이다.

그러니까 이 의존 역전 원칙이 리스코프나 개방-폐쇄를 따르는 설계를 만들어주는 밑바탕이 되는 것으로 볼 수 있다.

의존 역전

마땅한 코드 예시가 없어서 책에서 한번더 가져다 쓴다.

public class FlowController {
    public void process() {
        //소스 코드에서 FileDataReader에 대한 의존 발생
        FileDataReader reader = new FileDataReader();
    }
}

public class FileDataReader implements ByteSource {
    ...
}

이런 구조가 있다면

소스코드의 의존은 아래와 같다.

image
{: text-center}

그런데 런타임에서 본다면
FlowController -> FileDataReader 가 되는것이다.

왜냐면 컨트롤러가 시작점이고 거기서 하나씩 로드해서 참조하기 때문에

이런 구조가 나온다.

왜?

이유는 그냥 단순하다. 절차지향으로 생각해보면 저 Controller를 동작시켜야 아래가 동작하지 않는가?

정리

지금까지 SOLID 원칙에 대해 알아보았다. 결국 이 원칙이 말하는 바는

정말 원초적으로 생각하고 따지자면 유지보수하기 좋은 코드를 만드는 밑바탕이다.

왜냐면 요구사항이 변화하는것에 맞춰 유연하게 변경하니까 가 가장크다.

요즘 자바 공부에 재미를 붙였다.

이렇게 하나하나 습득해서 위로 올라가고 싶다.

나는 아직 자바가 고프다 ㅋㅋㅋㅋㅋㅋ

이번 포스팅 마치도록 하겠다.

728x90

'Java' 카테고리의 다른 글

디자인 패턴 - Bridge Pattern  (0) 2022.08.07
Oauth 이슈  (0) 2022.08.07
연산자  (0) 2022.08.06
[Java] 데이터타입, 변수, 배열  (0) 2022.08.06
728x90

연산자, 조건문 정리

  • 산술 연산자
  • 비트 연산자
  • 관계 연산자
  • 논리 연산자
  • instanceof
  • assignment(=) operator
  • 화살표(->) 연산자
  • 3항 연산자
  • 연산자 우선 순위
  • (optional) Java 13. switch 연산자

산술 연산자(Arithmetic Operator)

산술 연산자는 사칙연산을 다루는 연산자로, 가장 기본적이면서 가장 많이 사용되는 연산자 중의 하나.

산술 연산자는 모두 두 개의 피연산자를 가지는 이항 연산자이며, 결합 방향은 왼쪽에서 오른쪽이다.

연산자 설명
+ 더하기 연산 수행(문자열 연결 가능)
- 마이너스 연산 수행
* 곱하기 연산 수행
/ 나누기 연산 수행(정수형은 몫 연산자)
% 나머지 연산 수행
int result = 1 + 2;
System.out.println(result);

String ss = "연습";
System.out.println(ss + " 입니다.");

int product = 5 * 4;
System.out.println(product);

System.out.println("정수형 나누기");
System.out.println(5 / 4);
System.out.println(5 % 4);

System.out.println("실수형 나누기");
System.out.println(5.0 / 4.0);
System.out.println(5.0 % 4.0);


System.out.println(5 / 0); // java.lang.ArithmeticException: / by zero

System.out.println(5.0 / 0.0); // Infinity

System.out.println(5.0 % 0.0); // NaN

System.out.println(-5.0 / 0.0); // -Infinity

System.out.println(-5.0 % 0.0); // NaN

정수형일때 조심해야 하는 점은 보는 것과 같이 나눗셈 연산을 수행할 때이다.
그냥 실행이 잘 되기 때문에 % 연산을 수행할 때에도 음수값으로 나눌때 값이 다르다.

실수형일때는 연산 과정에서는 오차가 발생할 수 있고 이것을 조심해야 한다.
0으로 나누거나 나머지 연산을 사용하면 Infinity, NaN 가 나온다.

단항 연산자

  • + 를 붙이면 양수를 나타냄. (생략 가능)
  • - 를 붙이면 음수 값을 나타낸다.

증감 연산자

++를 붙이면 값을 1씩 증가시킨다. 하지만 피연산자의 위치에 따라 계산이 살짝 달라진다.

앞쪽에 ++변수 의경우 식을 진행하기 전에 1을 증가시키고, 뒤쪽변수++에 위치하는 경우 식을 진행한 후에 1을 증가시킨다.

-- 도 이와 같다.

int result = 1;
for (int i = 0; i < 5; i++) {
    System.out.print(result++ + " ");
}

System.out.println();
int result2 = 1;

for (int i = 0; i < 5; i++) {
    System.out.print(++result2 + " ");
}

image

두 증감 연산자에 대한 차이이다.

비트 연산자 (Bitwise Operator)

논리 연산자와 비슷하지만, 비트 단위로 논리 연산을 할 때 사용하는 연산자이다.

비트 단위로 왼쪽이나 오른쪽으로 전체 비트를 이동하거나 1의 보수를 만들 때도 사용된다.

연산자 설명
& 대응되는 비트가 모두 1이면 1을 반환함.(AND 연산)
대응되는 비트중 하나라도 1이면 1을 반환함.(OR 연산)
^ 대응되는 비트가 서로 다르면 1을 반환함.(XOR 연산)
~ 비트를 1이면 0으로, 0이면 1로 바꿈(NOT 연산, 1의 보수)
<< 명시된 수 만큼 비트를 전부 왼쪽으로 이동(left shift 연산)
>> 부호를 유지하면서 지정한 수 만큼 전부 오른쪽으로 이동(right shift 연산)
>>> 지정한 수만큼 비트를 전부 오른쪽으로 이동시키며, 새로운 비트는 전부 0이 됨.

관계 연산자

관계 연산자는 피연산자가 값이 어떤지 비교하는 연산자들이다.

  • == : 같다.
  • != : 다르다.
  • > : 크다
  • >= : 크거나 같다.
  • < : 작다.
  • <= : 작거나 같다.

문자열이 같은지를 보려면 equals()를 사용해야 한다.

==은 주소값을 비교하는 것이기 때문에 String 은 equals를 사용하자.

논리 연산자

피연산자 값으로 boolean을 받고 true/false를 판단할 경우에 사용한다.

  • &&
    • AND 연산자이며, 좌우 값이 둘다 참일 경우에 true, 그렇지 않으면 false를 반환
  • ||
    • OR 연산자로, 한쪽만 참이어도 참을 반환한다.
  • !
    • NOT 연산자로 위의 두개와는 다르게 단항 연산자이며 조건이 거짓일때 true값을 반환해준다. 그러니까 기본이 거짓이면 true, 참이면 false를 반환한다.

instanceOf

객체 타입을 확인하는데 주로 사용하고, 나같은 경우에는 테스트 코드를 작성했을 때, 많이 사용한 메서드이다. 속성 자체는 연산자이고, 형 변환이 가능한지 여부를 true/false 로 가르쳐준다. 부모, 자식 객체인지 확인하는데 쓴다고 생각하면 된다.

class A {

}

class B extends A {

}

public class InstanceTest {
  public static void main(String[] args) {
    A a = new A();
    B b = new B();

    System.out.println(a instanceof A);
    System.out.println(b instanceof A);
    System.out.println(a instanceof B);
    System.out.println(b instanceof B);
  }
}

결과는 true, true, false, true 가 나온다.

b는 부모 클래스인 a를 상속받았기 때문에 A의 객체타입이 맞다.

그래서 true이고 A클래스는 반대로 B에 대한 부모 클래스이기 때문에 B의 객체 타입일 수는 없으므로 false를 출력하게 된다.

assignment(=) operator

할당 연산자는 연산자 기준 오른쪽값을 왼쪽의 피연산자에게 할당한다.

int x = 0; 에서 x라는 int형 변수에 0을 할당하는 것이다.

A a = new A(); 처럼 객체를 할당할 수도 있다.

복합 할당

public class Test {
  public static void main(String[] args) {
      int a = 0;
      a = a + 1;
      a += 1;
  }
}

a = a + 1 의 경우

image1

a += 1 의 경우

image2

서로 바이트 코드가 다르다!!!

아래의 경우가 속도가 조금 빠르지 않을까 생각한다.

화살표(->) 연산자

화살표 연산자는 Java8에서 람다 표현식으로 익명클래스를 대체한 것이다.

public interface Test {
    void test();
}

public class Main {
    public static void main(String[] args){
        Test test = new Test() {
            @Override
            public void test() {
                //로직 구현
            }
        };
    }
}

이런식으로 익명 클래스를 사용하여 구현을 해줬었다.

근데 이 화살표 연산자가 생긴뒤로는 이렇게 구현하지 않고 아래와 같이 구현한다.

@FunctionalInterface
public interface Test {
    void test();
}

Test test = () -> 로직;

이렇게 구현을 해준다.

@FunctionalInterface 를 인터페이스에 붙여주게 되면 interface에는 하나의 추상메서드만 정의가 가능하다.

람다를 다시 공부할때 포스팅 하도록 하겠다.

3항 연산자

삼항 연산자는 조건 ? 참일 경우 : 거짓일 경우 로 구현하는데 피연산자를 세개를 받으므로 삼항 연산자라고 불린다.

if-else문과 비슷한 역할을 수행하지만 한줄로 작성이 가능하다.

public class Ternary {
    public static void main(String[] args) {
        int n1 = 5;
        int n2 = 10;

        int max = (n1 > n2) ? n1 : n2;
        System.out.println("max : " + max);
    }
}

기본적으로 연산자에는 우선순위가 있으며, 괄호의 우선순위가 제일 높고,

산술 > 비교 > 논리 > 대입의 순서이며, 단항 > 이항 > 삼항의 순서다.

연산자의 연산 진행방향은 왼쪽에서 오른쪽으로 수행되며,

단항 연산자와 대입 연산자의 경우에는 오른쪽에서 왼쪽으로 수행된다.

image3

switch 연산자

어떠한 값이 맞는다면 해당하는 식을 수행하게끔 만든 연산자이다.

단일 값으로 평가되는 하나의 표현식.

java 15 버전에서는 case를 case -> 로 표현한다고 한다.

public class Main {
    public static void main(String[] args) {
        String day = "월";

        switch (day) {
            case "월":
                System.out.println("월요일");
                break;
            case "화":
                System.out.println("화요일");
                break;
            case "수":
                System.out.println("수요일");
                break;
            case "목":
                System.out.println("목요일");
                break;
            case "금":
                System.out.println("금요일");
                break;
        }
    }
}

이것을

switch (day) {
    case "월" -> System.out.println("월요일");
    case "화" -> System.out.println("화요일");
    case "수" -> System.out.println("수요일");
    case "목" -> System.out.println("목요일");
    case "금" -> System.out.println("금요일");
    case "토", "일" -> System.out.println("주말");
}

이렇게 표현이 가능하다.

if-else문은 원하는 조건이 나올때까지 순서대로 모든 경우를 비교하고

switch문은 jump-table을 사용해서 한번에 원하는 곳에 이동한다.

그래서 if문은 조건문의 개수만큼 O(n)의 시간복잡도를 갖게 되어 성능에 단점이 있고,

switch문은 case의 개수만큼 jump-table을 차지하므로 메모리에 단점이 있다.

때문에 성능면으로 보면 switch문이 더 빨라서

조건이 3개 이상일 경우에는 switch를 사용하는 것이 더 좋다고 한다.

그렇지만 사실 그 차이는 컴파일러의 처리 속도에 따라 차이가 생기는 것이고

요즘 컴파일러들이 워낙 우수하기 때문에 차이가 미비하다고 하다.

if-else 를 쓰든 switch 를 쓰든 (특별히 성능과 메모리의 이슈를 갖고 있지 않은 이상) 각자의 취향이 아닐까.

가독성을 높이는 쪽으로 선택해서 사용하면 될 것 같고

나는 되도록이면 Early Return을 하려고 한다.

String string;

if (조건) {
    string = "참";
} else {
    string = "거짓";
}

이런게 있다고 하면 조건에 부합하는 문자열만 보내주면 된다.

그래서

String string = getString();

public String getString() {
    if (조건) {
        return "참";
    }

    return "거짓";
}

이렇게 분리하는 방법이 좋은것 같다.

이걸 더 분리한다고 치면 이제 공부했던 State패턴을 사용하면 되겠다.

이거는 다시 정리하는것이기 때문에 나중에 객체지향 개념을 들어가게 되면 구체적으로 정리를 해보도록 하겠다.

728x90

'Java' 카테고리의 다른 글

Oauth 이슈  (0) 2022.08.07
SOLID 원칙  (0) 2022.08.06
[Java] 데이터타입, 변수, 배열  (0) 2022.08.06
JVM  (0) 2022.08.06

+ Recent posts