어댑터 패턴

어댑터 패턴은 호환되지 않은 인터페이스를 연결하는 디자인패턴입니다. 어댑터 패턴은 어려운거 없으니 바로 확인해보겠습니다.

 

 

 

 

 

public interface Target {

    void doSomething();
}
public class Adaptee {

    public void action() {
        System.out.println("action");
    }
}

 

Target과 Adaptee 는 서로 호환될 수 없는 관계입니다. 이 관계를 호환되게 만드는 것이 어댑터 패턴입니다.

 

 

우리는 기존코드를 수정하지 않으면서 Target 을 통해 Adaptee 클래스를 통제하고싶습니다. 그래서 두 클래스 사이에 새로운 클래스를 추가하겠습니다.

 

public class Adapter implements Target {

    private final Adaptee adaptee;

    public Adapter(Adaptee adaptee) {
        this.adaptee = adaptee;
    }

    @Override
    public void doSomething() {
        adaptee.action();
    }
}

 

어댑터 패턴 끝났습니다.

 

 

 

Target 인터페이스를 Adapter 클래스에서 상속받고 필드에 Adaptee 를 받아 doSomething 에서 action 메소드를 오버라이딩 했습니다.

 

 

 

이 어댑터 패턴은 기존 코드의 수정 없이 호환되지 않는 클래스를 이어줄 수 있다는 장점이 있습니다. 다만 호환되지 않는 클래스의 연결로 인해 코드 복잡성이 증가하면서 유지보수가 어려워 질 수 있다는 단점도 존재합니다.

개요

if-else 와 switch문은 성능 상 약간의 차이가 있습니다. 오늘은 그 구조를 이해해보고 언제 사용해야 좋을지 바이트코드를 알아보도록 하겠습니다.

 

 

if

int i = 1;

if (i == 1) {
    method1();
} else if (i == 3) {
    method2();
} else {
    method3();
}

 

int i의 값이 1일때 method1()을 실행하고, i의 값이 3일때 method2()를 실행하고, 나머지는 method3()을 실행하는 아주 간단한 구조를 가진 if 문입니다. 이 코드를 bytecode로 변환해보겠습니다.

public static main([Ljava/lang/String;)V
   L0
    LINENUMBER 7 L0
    ICONST_1
    ISTORE 1
   L1
    LINENUMBER 9 L1
    ILOAD 1
    ICONST_1
    IF_ICMPNE L2
   L3
    LINENUMBER 10 L3
    INVOKESTATIC functional/Main.method1 ()V
    GOTO L4
   L2
    LINENUMBER 11 L2
   FRAME APPEND [I]
    ILOAD 1
    ICONST_3
    IF_ICMPNE L5
   L6
    LINENUMBER 12 L6
    INVOKESTATIC functional/Main.method2 ()V
    GOTO L4
   L5
    LINENUMBER 14 L5
   FRAME SAME
    INVOKESTATIC functional/Main.method3 ()V
   L4
    LINENUMBER 17 L4
   FRAME SAME
    RETURN
   L7
    LOCALVARIABLE args [Ljava/lang/String; L0 L7 0
    LOCALVARIABLE i I L1 L7 1
    MAXSTACK = 2
    MAXLOCALS = 2

 

구분을 좀 나누면 이렇게 됩니다.

구조를 잘 보시면

  1. L1에서 조건이 일치하면 L3으로 이동하여 method1을 실행하고, 일치하지 않으면 IF_ICMPNE L2 로 이동합니다.
  2. L2에서 조건이 일치하면 L6으로 이동하여 method2를 실행하고, 일치하지 않으면 IF_ICMPNE L5 로 이동합니다.
  3. else 에서 method3을 실행합니다.

if 문은 결과가 true 가 나오기전까지 순서대로 비교연산을 수행해야합니다. 만약 비교문이 10개라면 최대 10번 비교연산이 수행될 수 있단는 것입니다.

 

 

 

switch

 

int i = 1;

switch (i) {
    case 1 -> method1();
    case 3 -> method2();
    default -> method3();
}
  public static main([Ljava/lang/String;)V
   L0
    LINENUMBER 7 L0
    ICONST_1
    ISTORE 1
   L1
    LINENUMBER 9 L1
    ILOAD 1
    LOOKUPSWITCH
      1: L2
      3: L3
      default: L4
   L2
    LINENUMBER 10 L2
   FRAME APPEND [I]
    INVOKESTATIC functional/Main.method1 ()V
    GOTO L5
   L3
    LINENUMBER 11 L3
   FRAME SAME
    INVOKESTATIC functional/Main.method2 ()V
    GOTO L5
   L4
    LINENUMBER 12 L4
   FRAME SAME
    INVOKESTATIC functional/Main.method3 ()V
   L5
    LINENUMBER 15 L5
   FRAME SAME
    RETURN
   L6
    LOCALVARIABLE args [Ljava/lang/String; L0 L6 0
    LOCALVARIABLE i I L1 L6 1
    MAXSTACK = 1
    MAXLOCALS = 2

 

 

switch 문을 바이트코드로 변환했을 때, 이 부분에서 switch 문이 발생된걸 확인할 수 있습니다. 위에 if-else 문과 구조가 다른데요 눈여겨 볼 것은 LOOKUPSWITCH 입니다.

LOOKUPSWITCH 는 조건마다 비교연산을 하는게 아니고 표현식 값을 가지고 탐색합니다.

if-else 보다 비교연산 없이 탐색하기 때문에 효율적이지만, jump table 생성 비용의 오버헤드가 있습니다.

 

LOOPUPSWITCH, TABLESWITCH 에 대해서  조금 더 Alaboza

더보기

TABLESWITCH

int i = 1;

int j = switch (i) {
    case 0 ->  0;
    case 1 ->  1;
    case 2 ->  2;
    case 5 ->  5;
    case 10 ->  10;
    default -> -1;
};

 이 switch문을 바이트 코드로 바꾸면 어떻게 될까?

 

   L1
    LINENUMBER 9 L1
    ILOAD 1
    TABLESWITCH
      0: L2
      1: L3
      2: L4
      3: L5
      4: L5
      5: L6
      6: L5
      7: L5
      8: L5
      9: L5
      10: L7
      default: L5

우리는 0, 1, 2, 5, 10 에대한 case를 작성했는데 바이트 코드를 보면 TABLESWITCH 안에 0 - 10까지의 데이터가 들어가있습니다. 0, 1, 2, 5, 10을 제외한 나머지 숫자들은 switch 종료 라인인 L5 를 가리키고 있습니다.

 

 TABLESWITCH는 전체 범위를 인덱싱 하기 때문인데요. 이 과정에서 사용되지 않은 빈 공간까지 차지하게 되어버렸습니다.

 

LOOKUPSWITCH와 TABLESWITCH는 컴파일과정에서 효율성을 따져서 자동으로 이 방식이 선택됩니다.

만약 인덱싱과정이 사용된다면 더더욱 switch문을 써야겠죠?

 

 

그래서 뭐가 더 좋을까?

코드를 작성할 때, 성능도 중요하지만 가독성에 대한 것도 고려해야합니다. JAVA 17부터 switch 문이 업데이트되면서 기존의 if-else 보다 가독성이 좋아졌습니다.

 

// switch 문
switch (i) {
    case 1:
        method1();
        break;
    case 3:
        method2();
        break;
    default:
        method3();
        break;
}

// JAVA 17 에서의 switch 문

switch (i) {
    case 1 -> method1();
    case 3 -> method2();
    default -> method3();
}

switch (i) { // 다수 조건식
    case 1,2 -> method1();
    case 3 -> method2();
    default -> method3();
}


// JAVA 21 에서의 switch 문 
switch (animal) { // 타입 캐스팅
    case Cat c -> c.sound();
    case Dog d -> d.sound();
    case Cow c -> c.sound();
    default -> "Unknown";
};

switch (animal) { // Null 처리
    case Cat c -> c.sound();
    case Dog d -> d.sound();
    case Cow c -> c.sound();
    case null -> "Null";
    default -> "Unknown";
};

 

 

따라서, 가독성에 중점을 두거나 조건이 많을때는 switch 문을 사용하는것도 고려해볼만 합니다.

들어가며

이 글에서는 StringBuilder, StringBuffer 차이점뿐만 아니라 사용 이유에 대해서도 다루고있습니다.

 

[JAVA] String의 불변성(immutability)

들어가며JAVA를 처음 접할 때, String은 불변객체라고 배우고 지나갔습니다. 하지만 실제 String을 다루다보면 어째서 불변객체인지 의문이 들곤합니다.  String str = "apple"; str = "banana";왜냐하면, 재할

tmd8633.tistory.com

 

이전 글에서 String의 불변성에 대해서 읽어보시는걸 추천드립니다.

 

 

불변성

String 은 불변성입니다.

StringBuffer, StringBuilder 는 가변성입니다.

 

        String str = "1";
        System.out.println("hashCode = " + str.hashCode());
        System.out.println("====================================");
        String[] arr = {"2", "3", "4"};
        for (String s : arr) {
            str += s;
            System.out.println("hashCode = " + str.hashCode());
        }
//        hashCode = 49
//        ====================================
//        hashCode = 1569
//        hashCode = 48690
//        hashCode = 1509442

 

String은 불변객체이기때문에 String Constant Pool에서 str += s 될때마다 불필요한 새로운 데이터를 heap 영역에 저장합니다.

System.out.println("12".hashCode()); // 1569

 

실제로 중간과정에서 만들어진 String의 hashCode를 가져와보면 Pool에서 불필요한 데이터가 저장된걸 볼 수 있습니다.

 

 

여기에서 StringBuffer or StringBuilder를 사용하면 어떻게 될까요?

        StringBuilder sb = new StringBuilder("1");
        System.out.println("hashCode = " + sb.hashCode());
        System.out.println("====================================");
        String[] arr = {"2", "3", "4"};
        for (String s : arr) {
            sb.append(s);
            System.out.println("hashCode = " + sb.hashCode());
        }
//        hashCode = 918221580
//        ====================================
//        hashCode = 918221580
//        hashCode = 918221580
//        hashCode = 918221580

가변객체이기때문에 hashCode가 변하지 않은것을 확인할 수 있습니다.

 

String의 불변특성때문에 문자열 더하기의 연산은 불필요한 데이터를 저장하게되며, hashCode의 캐싱에도 불필요한 데이터가 저장된다는 문제가 있습니다. 따라서 String의 문자열 더하기는 StringBuilder 나 StringBuffer를 사용하도록 해야합니다.

 

그러면 StringBuilder와 StringBuffer는 무슨 차이가 있을까요?

 

 

StringBuilder vs StringBuffer

 

Builder와 Buffer는 같은 추상클래스를 상속받고 있고, 그 구조가 완벽하게 동일합니다. 그 차이점은 구현체에서 찾을 수 있습니다.

 

StringBuffer는 동기화를 지원하고 (Thread-Safe),

StringBuilder는 동기화를 지원하지 않습니다.

 

 

 

StringBuffer 대부분의 메소드에는 synchronized 키워드가 붙어있습니다.

 

 

 

언제 어떻게 사용하면 될까?

 

StringBuffer

동기화를 지원하기때문에 멀티스레드 환경에서 안전하게 동작합니다. 동시성 문제를 고려해야한다면 StringBuffer를 사용해야합니다.

 

StringBuilder

StringBuilder는 StringBuffer 보다 일반적으로 성능상 빠릅니다. 동기화 처리가 없기 때문입니다. 따라서 단일스레드 환경에서는 불필요한 동기화 처리를 하지 않는 StringBuilder를 사용해야합니다.

 

 

 

사용 시 주의점

 

1. capacity

StringBuilder, StringBuffer 둘다 용량증가정책은 동일하게 설정되어있습니다. 이 capacity는 문자열의 수용량입니다. 기본 capacity는 16입니다.

 

 

 

Collection List를 최적화해보자

Collection List Collection중에 가장 많이 사용하는 것은 아마도 List일것입니다. List에는 여러 구현체가 있습니다. ArrayList, LinkedList, Vector 가 있는데 오늘 알아볼것은 ArrayList와 LinkedList입니다. 최적화

tmd8633.tistory.com

이전 글에서 List의 최적화를 다뤘는데 List의 initialCapacity와 같은 개념입니다.

 

 

append 메소드를 사용할때, capacity가 가득차면 추상클래스에서 ensureCapacityInternal 메소드를 통해 capacity를 2배증가시킵니다.

 

이 과정에서 StringBuffer는 동기화 처리과정에서 오버헤드가 발생할 수 있습니다. (락 획득과 해제, 락에 의한 대기시간 증가 등)

 

capacity는 생성자에서 사용자가 조절할 수 있습니다.

 

 

 

2. null 처리

        String str = null;

        StringBuilder sb = new StringBuilder("1");
        sb.append(str);
        System.out.println("sb = " + sb); // sb = 1null

        StringBuffer sb2 = new StringBuffer("1");
        sb2.append(str);
        System.out.println("sb2 = " + sb2); // sb2 = 1null

        String a = "1";
        a += str;
        System.out.println("a = " + a); // a = 1null

 

StringBuilder, StringBuffer 에 null이 append 되었을 때 따로 처리해주지 않습니다. null 이 들어오면 "null" 문자열로 인식되어 append 됩니다. 사용하기 전에 null 처리를 반드시 해야됩니다.

들어가며

JAVA를 처음 접할 때, String은 불변객체라고 배우고 지나갔습니다. 하지만 실제 String을 다루다보면 어째서 불변객체인지 의문이 들곤합니다.

 

        String str = "apple";
        str = "banana";

왜냐하면, 재할당에 대해서 문제가 없으니까요.

 

그런데 무슨 불변성이 있다는 것일까요? 이 글에서는 String 이 왜 불변성을 가지는지 알아보겠습니다.

 

 

1. String은 참조타입이다.

String의 불변성을 얘기하기 전에 알고 넘어가야할 부분이 몇가지 있습니다.JAVA에서 String 은 기본타입이 아닌 참조타입인 클래스라는 것입니다.

 

        String str1 = "apple";
        String str2 = "apple";
        System.out.println(str1 == str2); // true
        
        String str1 = new String("apple");
        String str2 = new String("apple");
        System.out.println(str1 == str2); // false

 

"" (큰따옴표)로 생성한 객체는 내용이 같으면 같음 메모리 주소를 가집니다.

new 연산자로 생성한 객체는 내용이 같더라도 다른 메모리 주소를 가집니다.

 

 

2. String Constant Pool

String은 Heap 영역에서 String Constant Pool 에서 관리되고있습니다.

""(큰따옴표)로 생성된 객체는 String Constant Pool 에서 관리되어 다음 동일한 값이 할당된다면 이전에 있던 참조값을 반환해줍니다. ( ==  true)

new 연산자를 사용한다면 String Constant Pool에 같은 값이 존재하더라도 별도의 주소를 가리키게됩니다. ( == false)

 

만약 위 그림에서 str2 = "apple" 이었다가 "banana"로 값이 변경된다면 어떻게 될까요?

변수 str2의 값이 "banana"로 변경된다면 String Constant Pool에서 값이 "banana"인 주소를 찾고 없다면 새로 생성하여 그 주소를 반환해줍니다. 이 과정에서 이전의 "apple" 데이터는 변경되지 않습니다.

 

 

 

불변인 이유

  • String Constant Pool에서 String 을 관리한다는것은 동일한 데이터에 대해 메모리주소를 공유함으로써  Heap 영역의 메모리가 절약한다는 뜻이됩니다. 이 과정에서 String 이 불변성을 유지해야지 메모리 주소를 공유하는 것이 가능합니다.
    만약 String이 불변객체가 아니라면 변수 str1과 str2가 "apple" 의 메모리주소를 공유하고있다고했을때 str2에서 값이 변경되는 순간 str1도 값이 변경되기 때문에 불변성을 가져야합니다.
  • String 객체에 대해 hashCode를 미리 계산하지 않아 메모리를 절약하고, 필요할 때만 계산하고, 계산된 후에는 재사용하여 성능을 향상 시킵니다. hashCode를 재사용한다는 것은 String의 불변성이 보장되어야 가능합니다. hashCode의 캐싱은 HashMap, HashSet과 같은 hash를 사용하는 연산에서 효과적입니다. 일반 객체는 사용할때마다 hashCode를 계산하지만 String은 캐싱된 hashCode를 사용하기 때문에 더욱 빠르게 연산하는 것이 가능합니다.
  • Sring은 불변이기 때문에 Thread-Safe합니다. 값이 변경되지않는 것이 보장된다면 동기화 문제가 발생할 수가 없습니다.
  • String이 불변이 아니라면 보안에 문제가 생길 수 있습니다. 데이터베이스의 username, password이나 host, port가 String값으로 전달된다고 했을때, 이 값이 불변이 아니라면 공격으로부터 값이 변경될 수 있기 때문입니다.

 

이상 String의 불변성에 대해서 알아보았습니다. 문자열의 기본타입이 존재하지않는 이유가 String의 불변성과 관련이 있다는 것을 알아가는 시간이었습니다. 

 

android 빌드를 할때 

flutter Module was compiled with an incompatible version of Kotlin. The binary version of its metadata is 1.8.0, expected version is 1.6.0.

라는 메세지가 떴습니다.

 

 

android - settings.gradle

 

com.android.application version 을 7.3.0 -> 7.4.2 로 변경하면 됩니다.

'Language > Flutter' 카테고리의 다른 글

[Flutter] 상태 관리 기초 - setState  (0) 2024.11.18
[Dart] Spread 연산자(...)  (0) 2024.11.17
[Dart] Null Safety  (0) 2024.11.16
[Dart] var, dynamic, final, late, const 키워드  (0) 2024.11.15
[Flutter] Widget의 크기제약  (0) 2024.11.07

이전 글

 

[Spring] JAVA로 금지어 검사기를 만들어보자

개요웹이나 앱을 개발하다보면 사용자가 입력한 데이터를 검증하는 과정에서 어쩔 수 없이 빼놓는게 금지어였습니다. 왜냐하면 사용할만한 라이브러리가 존재하지 않았기때문입니다. (제가 못

tmd8633.tistory.com

이전 글에서 만들었던 금지어 검사기를 Maven Central Repository에 등록해보겠습니다.

 

 

프로젝트 라이브러리화

@Configuration
@ComponentScan("ban.inspector")
public class WordInspectorAutoConfig {

}

 

이전 프로젝트를 그대로 배포해버리면 @Component 클래스들이 스캔범위 밖으로 벗어나기때문에 @ComponentScan 으로 해당 패키지를 설정해줍니다.

 

 

resources 하위에 META-INF 하위에 spring 폴더를 만듭니다.

org.springframework.boot.autoconfigure.AutoConfiguration.imports 라는 이름으로 file을 만듭니다.

위에 파일 안에 @ComponentScan 이 붙은 클래스의 패키지주소를 작성합니다.

org.springframework.boot.autoconfigure.AutoConfiguration.imports 내부에 작성된 config 클래스의 패키지주소

 

 

이렇게 설정하면 외부라이버리를 Component 로 손 쉽게 사용할 수 있습니다.

 

 

 

 

Maven Central Repository 등록하기

2024년 3월 12일부터 issues.sonatype.org는 폐쇄되어 모든 등록은 중앙 포털을 통해 이루어지도록 변경되었습니다. 기존 Nexus repository(OSSRH)로 올리는 방식을 사용하던 사용자는 기존 방식을 그대로 사용할 수 있지만, 신규 사용자라면 Maven Central을 통해서 라이브러리를 배포해야 합니다. 따라서 해당 게시글은 2024년 3월 12일 이후 적용되는 중앙포털을 통해 배포하는 방식입니다.

 

Maven Central Repository

Maven Central Repository는 Maven 프로젝트를 위한 공개 저장소입니다.

우리가 흔히 Maven Repository 에서 라이브러리를 찾아서 사용하는데 Maven Repository 는 라이브러리를 검색하는 곳이고, Maven Central Repository는 실제 파일을 등록하는 곳이라고 생각하시면 됩니다.

 

 

Maven Central

Official search by the maintainers of Maven Central Repository.

central.sonatype.com

 

 

1. namespace 만들기

 

회원가입을 하고 상단에 계정을 눌러 View Namespaces 를 눌러 이동합니다.

라이브러리를 github를 통해 배포하신다면 GitHub로 회원가입하는게 편합니다.

저는 github로 배포할것이기때문에 github로 회원가입했습니다.

 

 

GitHub로 회원가입을 하시면 github 주소로 자동생성됩니다.

 

 

 

 

2. GPG 키 생성

중앙 저장소에 라이브러리를 등록하기 위한 키 입니다.

 

 

2-1 설치

MAC은 아래 명령어로 설치하시면되고,

$ brew install gnupg

 

Windows 는

 

GnuPG - Download

Note that you may also download the GNU Privacy Guard from a mirror site close to you. See our list of mirrors. The table below provides links to the location of the files on the primary server only. These are the canonical release forms of GnuPG. To use t

gnupg.org

여기 들어가서 Gpg4WIn 을 다운받아 설치하시면 됩니다.

 

 

설치가 완료되었다면 gpg --version 을 통해 정상적으로 설치되었는지 확인합니다.

 

 

2-2 키생성

$ gpg --gen-key

키를 발급하는 과정에서 이름, 이메일, 비밀번호를 요구하는데 양식에 맞춰서 잘 작성하시면 됩니다.

비밀번호는 잊어버리지 않게 메모장에 작성해둡니다.

 

 

2-3 생성된 키 확인

$ gpg --list-keys --keyid-format short
[keyboxd]
---------
pub   ed25519/85683A34 2024-09-16 [SC] [expires: 2027-09-16]
      4520354F3D5C07DEDC90D716DDFE142685683A34
uid         [ultimate] xxxxxx Kim <xxxxxx@naver.com>
sub   cv25519/23DD4259 2024-09-16 [E] [expires: 2027-09-16]

키가 생성된 것을 확인할 수 있습니다. 여기 ed25519/85683A34 에서 뒤부분 8자리가 key ID가 됩니다. 

 

 

2-3 Key 등록

$ gpg --keyserver keyserver.ubuntu.com --send-keys 85683A34

맨 뒤 부분에 key ID를 넣어줍니다.

이 세개의 주소중 하나를 선택해서 등록해주시면 됩니다.

 

 

 

2-4 Pgp 파일 생성

$ gpg --export-secret-keys 85683A34 > /Users/user/Desktop/signing.pgp

pgp 파일을 생성할 위치를 뒤에 작성해줍니다.

 

 

 

3. 프로젝트 설정

 

build.gradle

plugins {
	...
	id "com.vanniktech.maven.publish" version "0.28.0"
	id 'signing' // GPG 서명을 위한 플러그인
}

plugins 안에 com.vanniktech.maven.publish 와 signing 을 추가합니다.

 

signing {
	sign publishing.publications
}

signing 을 추가합니다.

 

 

build.gradle 최상단에

import com.vanniktech.maven.publish.SonatypeHost

임포트를 해줍니다.

 

 

 

mavenPublishing {
	publishToMavenCentral(SonatypeHost.CENTRAL_PORTAL)

	signAllPublications()

	coordinates("io.github.kimseungwo", "wordinspector", "0.0.1") // 네임 스페이스, 라이브러리 이름, 버전 순서로 작성

	pom {
		name = "Maven Repository에 노출될 라이브러리명"
		description = "라이브러리 소개글"
		inceptionYear = "2024"
		url = "<https://github.com/KIMSEUNGWO/BanWordInspector>"
		licenses {
			license {
				name = "The Apache License, Version 2.0"
				url = "https://www.apache.org/licenses/LICENSE-2.0.txt"
				distribution = "https://www.apache.org/licenses/LICENSE-2.0.txt"
			}
		}
		developers {
			developer {
				id = "tmd8633"
				name = "이름"
				url = "https://github.com/KIMSEUNGWO"
			}
		}
		scm {
			connection = 'scm:git:github.com/KIMSEUNGWO/BanWordInspector.git'
			developerConnection = 'scm:git:ssh://github.com:KIMSEUNGWO/BanWordInspector.git'
			url = '<https://github.com/KIMSEUNGWO/BanWordInspector/tree/main>'
		}
	}
}

 

scm 작성법

connection = 'scm:git:github.com/[Github 사용자명]/[오픈소스 Repository 이름].git'
developerConnection = 'scm:git:ssh://github.com/[Github 사용자명]/[오픈소스 Repository 이름].git'
url = 'https://github.com/[Github 사용자명]/[오픈소스 Repository 이름]/tree/[배포 브랜치명]'

 

 

Maven Central Repository로 돌아와서 계정 - View Account - Generate User Token 클릭

OK 를 누르고 나온 username 과 password 를 메모장에 적어줍니다.

 

 

gradle.properties

mavenCentralUsername=[USER_NAME]
mavenCentralPassword=[USER_PASSWORD]
signing.keyId=[GPG_KEY_ID]
signing.password=[GPG_PASSWORD]
signing.secretKeyRingFile=[PGP_SIGNING_PATH]

프로젝트 루트폴더 내에 gradle.properties 를 생성해줍니다.

 

USER_NAME = Generate User Token 으로 생성된 username

USER_PASSWORD = Generate User Token 으로 생성된 password

GPG_KEY_ID = 2-3에서 생성된 키의 key Id

GPG_PASSWORD=2-2 키 생성시 등록한 비밀번호

PGP_SIGNING_PATH=2-4에서 생성된 pgp 파일의 절대경로

 

 

 

4. 배포

$ ./gradlew publishAllPublicationsToMavenCentralRepository

 

배포가 성공했다면

 

Maven Central Repository - 계정 클릭 - View Namespaces - Deployments 로 이동합니다.

 

VALIDATED 으로 상태가 변경되었다면 Publish 버튼이 활성화 됩니다. Publish를 눌러줍니다.

그럼 PUBLISHING 으로 변경되는데 수 분후에 PUBLISHED 로 변경되었다면 배포 성공입니다.

 

 

 

Maven Central Repository에서도 검색되고 Maven Repository에서도 검색이 됩니다. 이제 외부 라이브러리로 사용하시면 됩니다.

 

'일반 > CS' 카테고리의 다른 글

[CS][Spring Security] CORS에 대해서 알아보자  (0) 2024.12.16
[CS][Spring Security] CSRF란?  (0) 2024.12.12
[CS] MVC 패턴  (0) 2024.04.17
HTTP GET과 POST 차이  (0) 2024.01.25
URI와 URL의 차이점 (Feat : URN)  (0) 2024.01.21

개요

웹이나 앱을 개발하다보면 사용자가 입력한 데이터를 검증하는 과정에서 어쩔 수 없이 빼놓는게 금지어였습니다. 왜냐하면 사용할만한 라이브러리가 존재하지 않았기때문입니다. (제가 못찾을 것일수도 있습니다.)

그래서 Spring과 JAVA에 대해서 공부도 할겸 직접 라이브러리를 만들어보았습니다.

 

 

고르곤졸라는 되지만 고르곤 졸라는 안 돼! 배달의민족에서 금칙어를 관리하는 방법 | 우아한형

안녕하세요! 셀러시스템팀에서 서버 개발을 하고 있는 김예빈이라고 합니다. 배달의민족에는 금칙어를 관리하는 "통합금칙어시스템"이라는 것이 있습니다. 금칙어란? 법 혹은 규칙으로 사용이

techblog.woowahan.com

 

아이디어는 우아한기술블로그에서 가져왔으며 기술블로그에는 금지어 라이브러리를 제공하고 있지않습니다. 그래서 제가 직접 만드는겁니다.

 

 

어떤식으로 만들까?

금지어 검사기에 대한 대략적인 명세는 다음과 같습니다.

  1. 금지어는 json 데이터를 기반으로 데이터를 수집해야합니다.
  2. 검사기는 한번 로드되고 Singleton 이 보장되어야 합니다.
  3. 금지어이지만 예외단어는 금지어가 아니어야합니다. (예, "고르곤졸라" 에서 "졸라"는 금지어이지만 "고르곤졸라"는 예외단어)
  4. 사용자가 추가로 금지어나 예외단어를 추가할 수 있어야합니다.
  5. 금지어와 예외단어를 검사할때 띄어쓰기는 무시되어야합니다. (예, "AB가 금지어일경우 "A B"도 금지어)
  6. 금지어를 블라인드 할 수 있는 메소드가 있어야합니다.

이 정도로 정의하고 다음으로 넘어가겠습니다.

 

자료구조

클래스 설명
Word 금지어의 데이터
Inspector 사용자가 금지어 유무를 확인하는 최상위 클래스
InspecConfig 사용자가 금지어나 예외단어를 직접 추가할 수 있도록 하기위한 설정클래스
WordLoader Json 파일에서 단어들을 읽어오는 클래스
WordFactory 사용자가 factory에 직접 접근하지 않고 오로지 단어만 추가할 수 있도록 하는 클래스
WordFactoryBuilder<T> 금지어와 예외단어를 WordFactory를 통해 Json과 사용자로부터 주입받아 build 해주는 클래스
AbstractWordFactory<T> Factory의 공통 메소드 관리, 주입받은 단어들의 중복을 제거하고 build를 통해 WordUitl 에 데이터를 로드하는 클래스
BanWordFactoryImpl, ExceptWordFactoryImpl 생성자 주입용 구현체
BanWordUtil, ExceptWordUtil 자료구조인 WordUtil 를 통해 데이터를 제어하는 클래스
AbstractWordUtil BanWordUtil, ExceptWordUtil 의 공통 메소드 관리
BanWordUtil, ExceptWordUtil WordUtil, AbstractWordUtil의 구현체
WordUtil 데이터 자료구조

 

 

요청 시 예상 응답

// 금지어 : "바나나", "사과", "오렌지", "수박", "멜론"
// 검사할 문자열 : "바나 나 먹을래"

List<Word> words = inspector.inspect(검사할문자열);
// 예상 결과 : Word(word="바나나", startIndex=0, endIndex=4)


// 금지어 : "바나나", "사과", "오렌지", "수박", "멜론"
// 예외단어 : "사과주스"
// 검사할 문자열 : "사과주스 먹을래"

List<Word> words = inspector.inspect(검사할문자열);
// 예상 결과 : words.isEmpty() == true


// 금지어 : "바나나", "사과", "오렌지", "수박", "멜론"
// 검사할 문자열 : "사과먹을래"

String mask = inspector.mask(검사할문자열);
// 예상결과 : "?먹을래"


// 금지어 : "바나나", "사과", "오렌지", "수박", "멜론"
// 검사할 문자열 : "사과먹을래"
String mask = inspector.mask(검사할문자열, "X");
// 예상결과 : "X먹을래"

 

  1. 검사할 문자열을 inspect() 에 넣으면 금지된 단어의 문자열과 시작인덱스(includeIndex), 마지막인덱스(excludeIndex) 가 포함된 List 데이터가 반환 되어야 한다.검사할 문자열을 inspect() 에 넣으면 금지된 단어의 문자열과 시작인덱스(includeIndex), 마지막인덱스(excludeIndex) 가 포함된 List 데이터가 반환 되어야 한다.
    마지막인덱스는 띄어쓰기가 포함한 인덱스이다.
  2. 예외단어에 포함된 문자열은 금지어에서 제외한다.
  3. 검사할 문자열에서 금지된 단어를 블라인드하려면 mask() 메소드를 사용한다. 두번째인자로 "?" 대신 변경할 문자열을 넣으면 해당 문자로 변경되어 반환한다.

 

 

 

구현

 

1. Inspector

public interface Inspector {

    List<Word> inspect(String word);
    
    default String mask(String word) {
        return mask(word, "?");
    }
    
    default String mask(String word, String replace) {
        StringBuilder sb = new StringBuilder(word);
        List<Word> data = inspect(word);
        for (int i = data.size() - 1; i >= 0; i--) {
            sb.replace(data.get(i).startIndex(), data.get(i).endIndex(), replace);
        }
        return sb.toString();
    }
    
}

 

가장 먼저 Inspector 인터페이스를 만들어줍니다.

메소드 설명
inspect(String word) : List<Word> 검사할 문자열이 인자로 들어오면 금지어 목록을 반환합니다.
default mask(String word) : String 금지어들을 replace 할 기본 문자열을 "?" 로 정의해 반환합니다.
default mask(String word, String replace) : String 금지어들을 replace 문자열로 변환합니다.

 

※ 왜 StringBuilder를 썼을까?

  1. replaceAll 보다 StringBuilder를 사용하는게 더 빠릅니다.
  2. 금지단어가 여러개일 경우 for 문을 사용해 순환할 때 문자열의 길이가 변경되어 다음 순환 시 StringIndexOutOfBoundsException 가 발생할 수 있습니다.
  3. replaceAll에는 예외를 둘 수가 없습니다.
    2번을 보완해 기존 데이터를 살려두고 replaceAll을 하는 경우에는 금지단어가 제외되지만 같은 단어로써 예외단어까지 영향을 미칠 수 있습니다.
// 금지어 : ["사과", "바나나"]
// 예외단어 : ["사과주스"]

String word = "사 과랑바나나";

List<Word> banWords = [
	(Word: word="사과", startIndex=0, endIndex=3), 
	(Word: word="바나나", startIndex=4, endIndex=7)
];

// 1.
 for (Word data : banWords) {
	word = word.replaceAll(word.substring(data.startIndex, data.endIndex), replace);
    // StringIndexOutOfBoundsException 발생
 }
 
 // 2.
String word = "사과랑 사과주스";
String replace = "?";
 String temp = word;
  for (Word data : banWords) {
	word = word.replaceAll(temp.substring(data.startIndex, data.endIndex), replace);
 }
 // 예상한 결과 : ?랑 사과주스
 // 실제 결과 : ?랑 ?주스

 

 

 

2. Inspector 구현체

@Component
public class InspectorImpl implements Inspector {

    private final BanWordUtil banWordUtil;
    private final ExceptWordUtil exceptWordUtil;

    @Autowired
    public InspectorImpl(InnerInspectConfig config) {
        banWordUtil = config.getBanWordUtil();
        exceptWordUtil = config.getExceptWordUtil();
    }

    private List<Word> executeBanWord(String word) {
        return banWordUtil.filter(word);
    }

    private List<Word> executeExceptWord(String word, List<Word> beforeWords) {
        return exceptWordUtil.filter(word, beforeWords);
    }

    @Override
    public List<Word> inspect(String word) {
        return executeExceptWord(word, executeBanWord(word));
    }
}

 

메소드 설명
생성자 Configuration을 주입받아 BanWordUtil, ExceptWordUtil을 설정해줍니다.

 

mask() 메소드는 default 에 정의해놓았기때문에 Override를 하지 않았습니다.

executeExceptWord(word, executeBanWord(word));

메소드는 예외단어처리(검사할문자열, 금지어단어목록) 으로 보시면됩니다.

 

 

3. Config

@Component
public class InnerInspectConfig {

    private final WordFactoryBuilder<BanWordUtil> banWordFactory;
    private final WordFactoryBuilder<ExceptWordUtil> exceptWordFactory;
    private final WordLoader wordLoader;
    private InspectConfig inspectConfig;

    @Autowired
    public InnerInspectConfig(WordFactoryBuilder<BanWordUtil> banWordFactory, WordFactoryBuilder<ExceptWordUtil> exceptWordFactory, WordLoader wordLoader) {
        this.banWordFactory = banWordFactory;
        this.exceptWordFactory = exceptWordFactory;
        this.wordLoader = wordLoader;
    }

    @Autowired(required = false)
    public void setInspectConfig(InspectConfig inspectConfig) {
        this.inspectConfig = inspectConfig;
    }

    @PostConstruct
    private void onApplicationReady() {
        if (inspectConfig != null) {
            inspectConfig.addBanWords(banWordFactory);
            inspectConfig.addExceptWords(exceptWordFactory);
        }

        banWordFactory.add(wordLoader.readBanWords());
        exceptWordFactory.add(wordLoader.readExceptWords());
    }

    public BanWordUtil getBanWordUtil() {
        return banWordFactory.build();
    }

    public ExceptWordUtil getExceptWordUtil() {
        return exceptWordFactory.build();
    }

}

 

내부 설정을 담당하는 클래스입니다.

이곳에서 BanWordUtil과 ExceptWordUtil을 build해서 Inspector에 전달해주는 역할을 하게 만들었습니다.

 

메소드 설명
setInspectConfig 사용자로부터 금지어 및 예외단어를 추가할 수 있게 하기 위해 setter 주입으로 구현했습니다.
Autowired(required = false)를 함으로써 사용자가 Config를 구현하지 않더라도 문제가 없도록 구현하기 위해 required = false를 했습니다.
onApplicationReady 의존성 주입이 완료되고(@PostConstruct) 사용자가 추가한 데이터(inspectConfig)와 기본데이터인 json 데이터(wordLoader.read~)를 각 factory에 추가합니다.
getBanWordUtil
getExceptWordUtil
build 된 WordUtil을 Inspector에서 get 하는 메소드입니다.

 

 

4. WordLoader

public interface WordLoader {

    List<String> readBanWords();
    List<String> readExceptWords();
}

@Component
public class WordLoaderImpl implements WordLoader {

    private final Log logger = LogFactory.getLog(WordLoaderImpl.class);

    @Override
    public List<String> readBanWords() {
        return read("static/BanWords.json");
    }

    @Override
    public List<String> readExceptWords() {
        return read("static/ExceptWords.json");
    }

    private List<String> read(String path) {
        try {
            return new ObjectMapper().readValue(new ClassPathResource(path).getInputStream(), new TypeReference<>() {});
        } catch (IOException e) {
            logger.error(e);
            return Collections.emptyList();
        }
    }

}

 

Json 데이터를 읽어오는 클래스입니다.

 

 

 

5. WordFactory

public interface WordFactory {

    WordFactory add(List<String> words);

}

public interface WordFactoryBuilder<T extends AbstractWordUtil> extends WordFactory {

    T build();

}

 

WordFactory 인터페이스는 사용자로부터 금지어, 예외단어를 주입받기 위해 사용하는 클래스이고,

WordFactoryBuilder<T extends AbstractWordUtil> 는 Config에서 WordUtil을 build 하기 위해 사용되는 인터페이스입니다.

 

 WordFactoryBuilder 를 도입한 이유

  1. factory와 util 간의 메소드 분리
  2. 사용자로부터 build 메소드가 노출되지 않아야함
  3. BanWordFactory, ExceptWordFactory에 build는 무조건 실행되어야 하기때문에 interface로 강제하기위함

 

구현체

public abstract class AbstractWordFactory<T extends AbstractWordUtil> implements WordFactoryBuilder<T> {

    private final T wordUtil;
    private final Set<String> distinctWordSet = new HashSet<>();

    public AbstractWordFactory(T wordUtil) {
        this.wordUtil = wordUtil;
    }

    @Override
    public WordFactory add(List<String> words) {
        distinctWordSet.addAll(words);
        return this;
    }

    @Override
    public T build() {
        distinctWordSet.forEach(wordUtil::addWord);
        wordUtil.build();
        return wordUtil;
    }
}

@Component
public class BanWordFactoryImpl extends AbstractWordFactory<BanWordUtil> {

    @Autowired
    public BanWordFactoryImpl(BanWordUtil banWordUtil) {
        super(banWordUtil);
    }

}

@Component
public class ExceptWordFactoryImpl extends AbstractWordFactory<ExceptWordUtil> {
    
    public ExceptWordFactoryImpl(ExceptWordUtil exceptWordUtil) {
        super(exceptWordUtil);
    }
    
}

 

add된 words 는 Set으로 중복제거를 거쳐 마지막에 build로 wordUtil을 반환시킨다. Impl 인 구현체들은 생성자 주입을 담당하는 역할만 수행합니다.

 

 

 

6. AbstractWordUtil

public abstract class AbstractWordUtil {

    protected final WordUtil wordUtil;

    public AbstractWordUtil(WordUtil wordUtil) {
        this.wordUtil = wordUtil;
    }

    public void addWord(String word) {
        wordUtil.addWord(word);
    }


    public void build() {
        wordUtil.build();
    }

}

@Component
public class BanWordUtil extends AbstractWordUtil {

    public BanWordUtil(@Qualifier("ban") WordUtil wordUtil) {
        super(wordUtil);
    }

    public final List<Word> filter(String word) {
        return wordUtil.search(word);
    }

}

@Component
public class ExceptWordUtil extends AbstractWordUtil {

    public ExceptWordUtil(@Qualifier("except") WordUtil wordUtil) {
        super(wordUtil);
    }

    public final List<Word> filter(String newWord, List<Word> banWords) {
        return (banWords.isEmpty()) ? List.of() : expectFilter(newWord, banWords);
    }

    private List<Word> expectFilter(String newWord, List<Word> banWords) {
        List<Word> exceptWords = wordUtil.search(newWord);

        if (exceptWords.isEmpty()) return banWords;

        List<Word> newWords = new ArrayList<>();

        a:for (Word banWord : banWords) {
            for (Word exceptWord : exceptWords) {
                if (banWord.isInclude(exceptWord)) continue a;
            }
            newWords.add(banWord);
        }
        return newWords;
    }

}

 

자료구조를 제어하는 클래스입니다. BanWordUtil과 ExceptWordUtil에 반드시 존재해야하는 addWord(), build() 메소드를 추상클래스로 정의했습니다. 자료구조는 WordUtil 자리에 들어갈겁니다.

 

 

 

7. WordUtil

public interface WordUtil {

    void addWord(String word);
    void build();
    List<Word> search(String word);

}

 

가장 중요한 부분입니다. 바로 문자열 검사에 대한 알고리즘이 들어가는 부분인데요. 처음에 이 부분을 직접 구현해서 사용했었는데, 위의 우아한기술블로그에서 소개한 아호코라식 알고리즘 을 기반으로 커스텀해서 띄어쓰기를 허용한 데이터를 반환하게 구성했습니다.

 

@Component
public class AhoCorasickWordUtil implements WordUtil {

    private final TrieNode root;

    public AhoCorasickWordUtil() {
        this.root = new TrieNode();
    }

    static private class TrieNode {
        Map<Character, TrieNode> children = new HashMap<>();
        TrieNode failureLink = null;
        Set<String> output = new HashSet<>();
    }

    @Override
    public void addWord(String word) {
        TrieNode node = root;
        for (int i = 0; i < word.length(); i++) {
            node = node.children.computeIfAbsent(word.charAt(i), k -> new TrieNode());
        }
        node.output.add(word);
    }

    @Override
    public void build() {
        Queue<TrieNode> queue = new LinkedList<>();
        root.failureLink = root;

        for (TrieNode node : root.children.values()) {
            node.failureLink = root;
            queue.add(node);
        }

        while (!queue.isEmpty()) {
            TrieNode current = queue.poll();

            for (Map.Entry<Character, TrieNode> entry : current.children.entrySet()) {
                char c = entry.getKey();
                TrieNode child = entry.getValue();

                TrieNode failure = current.failureLink;
                while (failure != root && !failure.children.containsKey(c)) {
                    failure = failure.failureLink;
                }

                if (failure.children.containsKey(c) && failure.children.get(c) != child) {
                    child.failureLink = failure.children.get(c);
                } else {
                    child.failureLink = root;
                }

                child.output.addAll(child.failureLink.output);
                queue.add(child);
            }
        }
    }

    @Override
    public List<Word> search(String word) {
        List<Word> result = new ArrayList<>();
        if (word == null || word.isEmpty()) return result;
        TrieNode node = root;

        int realIndex = 0;
        int nonSpaceIndex = 0;

        Map<Integer, Integer> startIndices = new HashMap<>();

        for (int i = 0; i < word.length(); i++) {
            char c = word.charAt(i);

            if (Character.isWhitespace(c)) {
                realIndex++;
                continue;
            }

            startIndices.put(nonSpaceIndex, realIndex);

            while (node != root && !node.children.containsKey(c)) {
                node = node.failureLink;
            }

            if (node.children.containsKey(c)) {
                node = node.children.get(c);
            }

            for (String pattern : node.output) {
                int start = startIndices.getOrDefault(nonSpaceIndex - (pattern.length() - 1), -1);
                int end = realIndex + 1;

                if (start != -1) {
                    result.add(new Word(pattern, start, end));
                }
            }

            nonSpaceIndex++;
            realIndex++;
        }

        return result;
    }

}

 

8. Word

public record Word(String word, int startIndex, int endIndex) {

    public boolean isInclude(Word word) {
        return word.startIndex <= startIndex && word.endIndex >= endIndex;
    }

}

 금지어의 domain 객체입니다.

startIndex = includeIndex 이고, endIndex = excludeIndex 입니다.

 

 

 

테스트도 완료!

 

 

실행결과

@Configuration
public class TestConfig implements InspectConfig {

    @Override
    public void addBanWords(WordFactory factory) {
        factory
            .add(List.of("사과", "바나나", "오렌지", "수박"))
            .add(List.of("오이", "감자", "고구마"));
    }

    @Override
    public void addExceptWords(WordFactory factory) {
        factory.add(List.of("사과주스", "호박고구마"));
    }
    
}

 

json 기본 데이터 외에 사용자가 금지어와 예외단어를 추가할 수 있습니다.

 

 

 

 

잘 동작합니다.

 

 

마무리하면서 느낀점

이번 금지어 검사기는 즉흥적으로 시작했던터라 생각보다 시간이 좀 걸렸던 것같습니다. 자그마한 프로젝트를 하면서 배운게 몇가지가 있는습니다.

  1. JAVA 문자열 처리
    앞서 말한대로 알고리즘을 사용하기 전에 직접 만들어보며 문자열에 대한 이해도가 높아졌고, 아호코라식 알고리즘을 알게 되고, 사용해본게 가장 유용했습니다.
  2. 기능분리
    위의 자료구조를 보면
    - 설정파트를 담당하는 InspectConfig, InnerInspectConfig
    - JSON을 불러오는 WordLoader
    - 설정파트에서 데이터를 로드해 Util을 Builder 해주는 WordFactoryBuilder
    - 금지어, 예외단어를 처리하는 BanWordUtil, ExceptWordUtil
    - 문자열 처리를 담당하는 WordUtil
    - 사용자가 사용하는 Inspector
    이렇게 나누어져있는걸 볼 수 있습니다. 이런 역할을 분리하고, 인터페이스화, 추상화 단계를 직접 처리하며 기능분리에대한 이해도가 높아진것같습니다.
  3. 외부로 부터 주입받는 데이터
    가장 고심했던게 InspectConfig 를 사용하는 것이었습니다. 사용자가 인터페이스를 상속하지 않더라도 문제가 없어야하고, 상속했다면 데이터를 넣어주어야 하는것에 대해서 어떻게 해야하는지 고민이 많았습니다. 그 결과로 required = false의 setter 주입을 통해 이 문제를 해결했고, null 체크를 해서 데이터를 외부로부터 주입받는 방법으로 이 문제를 해결했습니다.

뭐 아무튼 그렇고, 이 문자열 검사기는 아직 미완성입니다. 완성이 되면 Maven Central Repository 에 업로드할 예정입니다.

 

9월 30일 업데이트!
WordLoader 객체를 삭제해서 json 파일을 더 이상 사용하지 않게 변경되었고,
https://mvnrepository.com/artifact/io.github.kimseungwo/wordinspector
0.0.10 버전부터 적용되었습니다.

 

 

 

public interface WordUtil {

    default void addWord(String word) { push(word, 0);}

    void push(String word, int index);

    int find(String str, int index, int deep, boolean ignoreSpace);

}

@Component
public class WordUtilImpl implements WordUtil {

    private final Map<Character, WordUtilImpl> data = new HashMap<>();

    @Override
    public void push(String word, int index) {
        if (word.length() <= index) return;

        char c = word.charAt(index);

        if (!data.containsKey(c)) data.put(c, new WordUtilImpl());
        data.get(c).push(word, index + 1);
    }


    @Override
    public int find(String str, int index, int deep, boolean ignoreSpace) {
        WordUtilImpl wordUtil = this;
        while (true) {
            if (wordUtil.data.isEmpty()) return deep == 0 ? -1 : deep;
            if (str.length() <= index) return -1;

            if (ignoreSpace && str.charAt(index) == ' ') {
                if (deep == 0) return -1;
                index++;    deep++;
                continue;
            }

            if (!wordUtil.data.containsKey(str.charAt(index))) return -1;
            wordUtil = wordUtil.data.get(str.charAt(index));
            index++;    deep++;
        }
    }

}

직접 만들어본 자료구조입니다..

함수형 인터페이스가 뭘까

함수형 인터페이스는 1개의 추상메소드를 가지고 있는데요. 당연하게도 아무런 역할을 하지 않는 인터페이스입니다.

java.util.function

 

이런 함수형 인터페이스는 function 패키지에 담겨져있습니다.

 

그 내용을 열어보면

많은 인터페이스들이 존재합니다. 이 함수형 인터페이스에는 똑같은 특징이 있는데 @FunctionalInterface 어노테이션을 사용하고있다는 겁니다. 이 어노테이션은 해당 인터페이스가 함수형 인터페이스 조건에 맞는지 검사해준다고 하는데요. 그렇지만 이 어노테이션을 사용하지 않더라도 동작하는데에는 문제가 없으니 참고해주시면 될것 같습니다.

 

함수형 인터페이스 정의

  1. 추상메소드가 단 한개인 인터페이스
  2. static, default 메소드가 여러개여도 추상메소드가 단 한개인 인터페이스

 

 

람다표현식

자바 8 이상을 사용하시는 분들은 lambda 에 대해서 익숙하실 겁니다. 함수형 인터페이스의 목적은 람다표현식을 이용해 함수형 프로그래밍을 구현하기 위해서라고 할 수 있습니다.

 

 

 

 

함수형 인터페이스 종류

function 패키지 이외에 Callable, Comparator 등의 함수형 인터페이스가 있지만 이 글에서는 function 패키지 내에서만 설명하겠습니다.

인터페이스 메소드 설명
Runnable void run() 매개변수를 받지 않고, 반환하지 않습니다.
Thread 에서 Runnable 을 사용하고있습니다.
Consumer<T> void accept(T t) 매개변수를 받고, 리턴하지 않을때 사용합니다.
forEach 메소드에서 이 인터페이스를 사용하고있습니다.
Function<T, R> R apply(T t) 제네릭 첫번째 타입을 매개변수로 받고,제네릭 두번째 타입을 반환합니다. Stream 에 map 메소드에서 사용하고있습니다.
Predicate<T> boolean test(T t) 매개변수를 받아 true / false 인지를 반환합니다.
Stream 에 filter에서 사용하고있습니다.
Supplier<T> T get() 매개변수를 받지않고, 어떤 값을 반환합니다.
Optional에 or(),orElseThrow() 등 메소드에서 사용하고있습니다.

 

 

 

 

1. Runnable

함수형 인터페이스 중에서 가장 애매한게 Runnable 이라고 생각합니다. 매개변수를 받지도 않고 반환값도 없는 이 인터페이스는 도대체 어디에 사용되는걸까요?

 

Thread 는 Runnable을 생성자 파라미터로 받아 사용하고 있는 것을 확인할 수 있습니다.

        Thread t = new Thread(() -> {
            for (int i = 0; i < 10; i++) {
                System.out.println(i);
            }
        });
        t.start();

 

Thread 에서 어떤 메소드를 실행할지에 대한 표현을 Runnable 를 사용하여 구현하였습니다. 이 Runnable 내에는 어떠한 함수가 와도 타입으로부터 자유롭습니다. 

 

 

 

2. Consumer

인터페이스 메소드 설명
Consumer<T> void accept(T t) 1개의 타입을 받음
BiConsumer<T, U> void accept(T t, U u) 2개의 타입을 받음
IntConsumer void accept(int value) 1개의 int 값을 받음
LongConsumer void accept(long value) 1개의 long 값을 받음
DoubleConsumer void accept(double value) 1개의 double 값을 받음
ObjIntConsumer<T> void accept(T t, int value) 1개의 타입과 1개의 int 값을 받음
ObjLongConsumer<T> void accept(T t, long value) 1개의 타입과 1개의 long 값을 받음
ObjDoubleConsumer<T> void accept(T t, double value) 1개의 타입과 1개의 double 값을 받음

 

 

 

@FunctionalInterface
public interface Consumer<T> {

    void accept(T t);

    default Consumer<T> andThen(Consumer<? super T> after) {
        Objects.requireNonNull(after);
        return (T t) -> { accept(t); after.accept(t); };
    }
    
}

 

이 Consumer 는 forEach 메소드에서 사용하는 것 외에는 어디다가 이 메소드를 사용해야 유용하지? 라는 의문이 들었습니다. 고민을 하다가 Consumer 인터페이스의 변수명을 이용해보자라는 생각이 들었는데요. 하나의 작업을 Consumer로 묶으면 변수명을 보고 로직의 이유를 바로 유츄가 가능하다는 점을 이용해보았습니다.

 

    public static void main(String[] args) {
        
        List<Order> orders = repository.getOrders();

        Consumer<List<Order>> 사용한쿠폰중만료된쿠폰삭제 = repository.consumeDeleteCoupon();
        Consumer<List<Order>> 금액환불 = repository.consumeRefund();
        Consumer<List<Order>> 영수증생성 = repository.consumeSaveReceipt();
        Consumer<List<Order>> 주문상태변경 = repository.consumeChangeCancelStatus();

        사용한쿠폰중만료된쿠폰삭제.andThen(
        금액환불.andThen(
        영수증생성.andThen(
        주문상태변경)))
        .accept(orders);

    }

 

물론 위에처럼 무턱대고 저렇게 하지는 않겠지만, 적절한 예시가 생각이 나지 않아 일단 이렇게 예시를 만들어봤습니다.

예를들어, 취소요청이 들어온 주문 건에 대한 비즈니스로직을 작성하려고합니다.

1. 사용된 쿠폰은 다시 사용할 수 있게 변경되어야 하고, 현재시간 기준 만료된 쿠폰은 삭제되어야한다.

2. 남은 금액은 환불되어야한다.

3. 환불건에 대해서 기록이 남아야한다.

4. 주문건은 삭제되지않고 기록을 남겨둔다.

 

라는 요청이라고 가정했을 때, 위와같이 Consumer 를 이용해서 각 기능별로 나눈다면 해당 작업에 대한 로직을 변수명을 보고 유추하기 쉽고, 작업의 순서를 파악하기도 쉬워졌습니다. 이렇게 로직을 분리해 결합도를 낮춰 사이드 이펙트에 대한 문제도 조금 해소되는것 같습니다.

 

추가로 andThen의 반환타입은 Consumer<T> 이기 때문에 이어 붙히는것이 가능합니다. 따라서 위의 코드를 더 정리한다면

    public static void main(String[] args) {
        List<Order> orders = service.getOrders();
        Consumer<List<Order>> consumer = service.getConsumeCancelOrder();
        consumer.accept(orders);
    }
    
public class Service {

    private static final Repository repository = new Repository();

    public Consumer<List<Order>> getConsumeCancelOrder() {
        Consumer<List<Order>> 사용한쿠폰중만료된쿠폰삭제 = repository.consumeDeleteCoupon();
        Consumer<List<Order>> 금액환불 = repository.consumeRefund();
        Consumer<List<Order>> 영수증생성 = repository.consumeSaveReceipt();
        Consumer<List<Order>> 주문상태변경 = repository.consumeChangeCancelStatus();

        return 사용한쿠폰중만료된쿠폰삭제.andThen(
            금액환불.andThen(
            영수증생성.andThen(
            주문상태변경)));
    }
}

accept와 Consumer 를 분리하는 것도 괜찮은것같습니다. 물론 getConsumeCancelOrder 를 호출할때마다 새로 생성할 필요가 없으므로 Spring의 Bean이나 static, Singleton 으로 관리하는 것이 옳지만 여기까지만 설명하도록 하겠습니다.

 

 

Consumer이의외 BiConsumer, IntConsumer 등등 사용법은 Consumer와 동일합니다. 다만 ObjXXXConsumer 인터페이스는 andThen 메소드가 없으니 유의바랍니다.

 

 

 

3. Function

인터페이스 메소드 설명
Function<T, R> R apply(T t) T를 받아서 R을 반환
Function<T, U, R> R apply(T t, U u) T와 U를 받아서 R을 반환
IntFunction<R> R apply(int value) int를 받아서 R을 반환
LongFunction<R> R apply(long value) long을 받아서 R을 반환
DoubleFunction<R> R apply(double value) double을 받아서 R을 반환
XXXToYYYFunction Y applyAsXXX(X value) XXX를 받아서 YYY를 반환
ToXXXFunction<T> X applyAsXXX(T t) T를 받아서 XXX를 반환
ToXXXBiFunction<T, U> X applyAsXXX(T t, U u) T와 U를 받아서 XXX를 반환

 

 

public enum Payment {

    KAKAO(i -> (int) (0.95 * i)),
    NAVER(i -> (int) (0.90 * i)),
    APPLE(i -> i),;

    private final Function<Integer, Integer> function;

    public int execute(int amount) {
        return function.apply(amount);
    }

    Payment(Function<Integer, Integer> function) {
        this.function = function;
    }
}

    public static void main(String[] args) {
        int amount = 10000;

        Payment payment = Payment.KAKAO;
        int execute = payment.execute(amount);
        System.out.println("execute = " + execute); // 9500
    }

 

제가 가장 즐겨사용하는 것이 Function 함수입니다. 특히 열거형타입과 함께 사용한다면 더욱 편해지는데요.

위의 예시 코드는 결제방식에 따라 결제금액이 할인되는(?) 말도안되는 로직이지만 이렇게 enum 타입을 사용한다면 조금더 직관적으로 사용할 수 있다는 장점이 있는것 같습니다.

 

 

 

4. Predicate

인터페이스 메소드 설명
Predicate<T> boolean test(T t) T를 받아 true/false 를 반환
BiPredicate<T, U> boolean test(T t, U u) T와 U를 받아 true/false 를 반환
IntPredicate boolean test(int value) int를 받아 true/false 를 반환
LongPredicate boolean test(long value) long을 받아 true/false 를 반환
DoublePredicate boolean test(double value) double을 받아 true/false 를 반환

 

 

    public static void main(String[] args) {

        Predicate<User> filterScore = user -> user.getScore() > 50;

        List<User> users = service.getUsers();

        for (User user : users) {
            if (filterScore.test(user)) {
                System.out.println(user.getName());
            }
        }

    }

 

Predicate는 매개변수로 들어온 데이터를 true / false로 반환하는 인터페이스입니다.

Stream을 사용할때 filter에서 이 Predicate 인터페이스를 받고 있습니다.

 

 

5. Supplier

인터페이스 메소드 설명
Supplier<T> T get() T를 반환
IntSupplier int getAsInt() int를 반환
LongSupplier long getAsLong() long을 반환
DoubleSupplier double getAsDouble() double을 반환
BooleanSupplier boolean getAsBoolean() boolean을 반환

 

Supplier도 어떻게 사용한느지 사실 조금 애매한 감이 있습니다. 데이터를 받지 않고 어떤 값을 반환한다는게 사실 감이 잡히지 않습니다.

Optional 에서 or() 이나 orElseThrow() 에서 사용하는것을 보았을때는 딱히 메리트있어보이는 함수는 아니었습니다. 그런데 조금더 찾아보니까 get()을 통해 Lazy Evaluation(지연 연산) 을 할 수 있다는 것을 알아냈습니다.

 

 

    public static void main(String[] args) {
        long start = System.currentTimeMillis();

        printOnlyValid(1, threadSleep());
        printOnlyValid(0, threadSleep());
        printOnlyValid(-1, threadSleep());

        long end = System.currentTimeMillis();
        System.out.println("걸린시간 : " + ((end - start) / 1000) + "초");
    }

    private static String threadSleep() {
        try {
            Thread.sleep(3000);
        } catch (InterruptedException e) {
            throw new RuntimeException(e);
        }
        return "Success";
    }

    private static void printOnlyValid(int index, String data) {
        if (index > 0) {
            System.out.println(data);
        } else {
            System.out.println("Invalid");
        }
    }

printOnlyValid 메소드를 실행하기에 앞서 String data 값을 가져오기 위해 threadSleep() 메소드를 실행하는 모습입니다.

threadSleep() 메소드는 3초의 시간이 걸리기때문에 3번의 실행으로 총 9초의 시간이 걸렸습니다.

 

이 메소드를 Supplier 함수를 사용하여 바꿔보겠습니다.

 

    public static void main(String[] args) {
        long start = System.currentTimeMillis();

        printOnlyValid(1, () -> threadSleep());
        printOnlyValid(0, () -> threadSleep());
        printOnlyValid(-1, () -> threadSleep());

        long end = System.currentTimeMillis();
        System.out.println("걸린시간 : " + ((end - start) / 1000) + "초");
    }

    private static String threadSleep() {
        try {
            Thread.sleep(3000);
        } catch (InterruptedException e) {
            throw new RuntimeException(e);
        }
        return "Success";
    }

    private static void printOnlyValid(int index, Supplier<String> data) {
        if (index > 0) {
            System.out.println(data.get());
        } else {
            System.out.println("Invalid");
        }
    }

printOnlyValid의 data 타입을 Supplier로 변경했기때문에 index > 0 일 경우에만 thradSleep() 메소드가 실행되어 3번의 메소드 실행 시간이 9초에서 3초로 줄었습니다.

 

 

결론

함수형 인터페이스는 그 종류도 많고 사용방법도 다양해 언제 어디서 사용할지 애매한 경우가 많은 것같습니다. 그래도 이 글을 작성하면서 JAVA 8 에서 함수형 인터페이스, Lambda, Stream 의 연결성에 대해서 공부가 된것같습니다.

 

공부하면서 작성한 글이기때문에 틀린부분이 있을 수 있습니다. 언제든지 지적해주시기 바랍니다.

이전 글

 

다대다(N:N) 관계는 왜 피해야할까?

다대다 관계 데이터베이스를 설계에서 다대다(N:N)관계는 피하는것은 일반적으로 권장되고있습니다. 하지만 자세한 설명이 추가되지않는다면 그 이유가 헷갈릴 수 있는데요. 이는 데이터의 무

tmd8633.tistory.com

 

이전 글에서 다대다(M:N) 관계를 해결하기 위해 1:N, N:1로 관계를 풀어주었습니다.

 

이를 통해 데이터 무결성과 중복성을 해결할 수 있었고, 유지보수와 확장에 대한 장점을 얻을 수 있었습니다.

하지만 이런 관계가 무한정 증가하다보면 관계가 복잡해지면서 이해하기가 어려워지는 단점이 존재합니다.

 

이번에는 1:N, N:1로써 해결하는것이 아닌 2진법을 통해 이 관계를 풀어보도록 하겠습니다.

 

 

2진법과 비트연산

컴퓨터를 조금이라도 공부해본사람이라면 2진법에 대해서 익숙하실겁니다. 따라서 간단하게 짚고 넘어가겠습니다.

 

2진법은 위와 같이 나타낼 수 있습니다. 다음은 이 수를 비트연산으로 표현해보겠습니다.

 

 

AND

두개의 2진수의 자리수가 둘다 1이면 1, 나머지는 0이되는 연산입니다.

 

 

OR

어느 하나라도 1이라면 1이되고, 둘다 0이라면 0이 되는 연산입니다.

 

 

XOR

두개의 값이 서로 다르면 1을 반환하고 그렇지 않으면 0을 반환합니다.

 

 

 

연산자는 더 있지만 이 글에서는 비트연산이 주제가 아니기때문에 생략하도록 하겠습니다.

 

그렇다면이 2진수 연산을 통해 어떻게 N:N관계를 풀수 있을까요?

 

 

 

2진법의 적용

기존의 1:N, N:1의 관계입니다.

1번의 홍길동 학생이 수강하는 과목은 JAVA, JS, Flutter, React 를 수강하고 그 과목코드는 1,2,3,4 입니다.

2번의 홍길순 학생이 수강하는 과목은 JAVA, JS 를 수강하고 그 과목코드는 1,2 입니다.

 

지금부터 한 단계씩 밟으며 관계를 정리해보겠습니다.

 

 

1. 과목코드 수정

현재 과목코드는 1 부터 오름차순으로 정의되어있습니다. 이를 2진수로 변경하겠습니다.

기존의 1,2,3,4 처럼 증가하는 수를 2의 지수로 표현하여 2의 0, 2의 1, 2의 2, 2의 3 로 나타내었습니다.

 

과목코드 과목명
0001 JAVA
0010 JS
0100 Flutter
1000 React

 

 

 

 

2. 수강테이블 합치기

우리는 위에서 잠깐 비트연산자 OR에 대해서 짚고 넘어갔습니다. 이 OR 연산자를 이용해서 수강테이블을 하나로 정리할 수 있습니다.

1번 홍길동의 수강목록 0001 + 0010 + 0100 + 1000 = 1111

2번 홍길순의 수강목록  0001 + 0010 = 0011

 

학번 수강코드
1 15 ( = 1111b)
2 3 ( = 0011b)

 

 

 

3. 수강테이블 삭제

학생과 수강 테이블간의 관계가 1:N에서 1:1로 변경되었습니다. 따라서 수강테이블을 삭제하더라도 1:N관계가 유지되므로 삭제해도 무방합니다.

 

과목코드를 직접 매핑시키지 않고 이와같이 사용할 수 있습니다.

최종적으로 수강테이블이 사라져 깔끔해졌습니다.

 

 

 

실제로 어떻게 사용해야할까?

실제로 조회, 추가, 삭제를 하려면 어떻게 해야할까요? 하나하나 알아봅시다.

 

아래부터 MySQL 기준으로 설명하겠습니다.

 

조회

홍길순 학생이 어떤과목을 수강하고 있는지 조회하려고합니다. 

 

SELECT *
FROM 학생
JOIN 과목 ON (학생.과목코드 & 과목.과목코드 = 과목.과목코드)

 

JOIN에서 비트연산을 해줍니다. 홍길순 학생의 수강하는 과목코드 3 ( = 0011b) 과 과목테이블의 과목코드 (0001, 0010, 0100, 1000)  가 같은지 확인해보면 됩니다. 

홍길순의 과목코드 첫번째자리는 JAVA의 1에 해당하는 위치입니다. 따라서 (홍길순의 과목코드) AND (JAVA 과목코드) = JAVA 과목코드 가 성립됩니다.

 

Flutter와 AND 연산을 했지만 그 결과는 0이고 이해 해당하는 과목은 존재하지 않으니 출력이 되지않습니다.

 

 

 

 

추가

홍길순 학생이 Flutter를 추가로 신청했을때 코드를 보겠습니다.

UPDATE 학생
SET 과목코드 = 과목코드 | ( SELECT 과목코드 FROM 과목 WHERE 과목명 = 'Flutter' )
WHERE 학번 = 2

 

서브쿼리에는 Flutter에 해당하는 과목코드만 존재하면 됩니다. 과목코드를 이미 알고있다면 서브쿼리를 사용하지 않고 바로 넣어줄 수 있습니다.

 

OR 연산자를 사용해서 기존 3 ( = 0011b) 에서 7 ( = 0111b) 로 업데이트 되었습니다. 

OR 연산의 장점은 만약 문제가 발생하여 Flutter 과목이 중복 수강이 되었어도, 중복으로 추가되지 않는다는것입니다. 

(0111 | 0100 = 0111)

 

 

 

삭제

이번엔 홍길동 학생이 JS 과목을 수강취소했을때의 코드입니다.

UPDATE 학생
SET 과목코드 = 과목코드 ^ ( SELECT 과목코드 FROM 과목 WHERE 과목명 = 'JS' )
WHERE 학번 = 2

XOR 연산을 통해 서로 다른값이어야 1을 반환하고 같은 값은 0으로 반환되었습니다.

 

 

 

결론

이 이진법을 이용한 테이블 관계매핑은 다대다 관계를 해소하기위한 보조 테이블들을 생성하지 않아 최적화에 도움이 되고, 데이터를 하나로 관리하는 편리함이 존재합니다.

하지만 단점으로는 데이터를 보관할 수 있는 한계가 존재한다는 점입니다.

데이터를 30개만 넣어도 값은 10억이 넘어가기 때문에 확장성을 고려해야하는 상황이라면 비트연산을 통한 매핑은 사용되어선 안됩니다.

오로지 한계가 정해져있는 부분에 사용되어야합니다.

 

최근 비트연산에 대해서 공부하고있는데 재미있어서 이렇게 글을 써보았는데, 혼자 공부하고 작성한 글이라 분명히 틀린부분이 존재할겁니다. 그대로 받아드리지 말고 참고만 해주시길 바랍니다. 틀린부분이 있다면 지적부탁드립니다.

'일반 > DB' 카테고리의 다른 글

[DB] Lock 이해하기  (0) 2025.01.15
다대다(N:N) 관계는 왜 피해야할까?  (1) 2024.02.29

MVC 패턴

MVC 패턴은 개발자라면 한번은 들어봤을겁니다. 오늘은 MVC 패턴에 대해서 알아보겠습니다.

 

 

 

MVC 패턴이란?

모델-뷰-컨트롤러(Model-View-Controller, MVC)는 소프트웨어 디자인 패턴 중 하나입니다. 이 패턴은 사용자 인터페이스(UI)로부터 비즈니스 로직을 분리하여 애플리케이션의 시각적 요소나 그 이면에서 실행되는 비즈니스 로직을 서로 영향 없이 쉽게 고칠 수 있는 애플리케이션을 만들 수 있습니다. 쉽게 말해 비즈니스 로직과 화면을 구분하는데 중점을 둔다는건겁니다.

MVC는 여러 파생되는 패턴을 가지고 있는데요. MVVM(Model-View-ViewModel), MVP(Model-View-Presenter), MVW(Model-View-Whatever)가 있습니다. 이 글에서는 MVC 에 대해서 알아보도록 하겠습니다.

 

 

 

Model, View, Controller의 관계

  • Model : 모델은 애플리케이션의 핵심 데이터와 비즈니스 로직을 나타냅니다. DB와의 상호작용, 데이터 처리 및 유효성 검사와 같은 작업을 수행합니다. 모델은 독립적으로 작동하며, 뷰와 컨트롤러와 직접적으로 통신하지 않습니다.
  • View : 사용자 인터페이스(UI)를 담당합니다. 모델에서 데이터를 받아 사용자에게 표시하고, 입력을 컨트롤러에 전달하는 역할을 합니다.
  • Controller : 사용자의 입력을 처리하고, 애플리케이션의 흐름을 관리합니다. 모델을 호출하여 데이터를 조작하거나 변경된 결과를 뷰에 전달하는 역할을 합니다.

출처 : wikipeida - 모델-뷰-컨트롤러

 

MVC 패턴을 설명할때 많이 사용하는 흐름도를 가져왔습니다.

 

이 개념을 웹에 적용한다면,

 

  1. USES : 사용자의 입력을 감지하고 Controller로 전달한다.
  2. MANIPULATES : Controller는 사용자가 요청한 웹 페이지를 보여주기 위해 Model 호출한다.
  3. UPDATES : 비즈니스 로직을 통해 데이터를 제어한 후 결과를 Controller에 반환하고 다시 Controller는 Model의 결과를 받아 View에 전달합니다.
  4. SEES : 데이터가 반영된 View가 사용자에게 보여준다.

 

 

MVC 패턴의 설계원칙

 

1. 각 구성 요소의 역할과 책임을 명확하게 구분

Model, View, Controller는 독립적으로 작동하고, 각각의 역할에 집중해야 합니다.

 

2. 구성 요소간의 결합도 최소화

구성 요소간의 직접적인 참조를 피해 의존성을 낮춰야합니다. 이를 통해 구성 요소를 독립적으로 개발, 수정, 테스트할 수 있습니다.

 

3. 코드의 재사용성과 확장성 고려

각 구성 요소는 독립적이고 재사용 가능한 모듈로 개발되어야합니다. 프로젝트의 규모가 코지거나 요구 사항이 변경되었을 때, 확장 및 수정이 용이해야합니다.

+ Recent posts