늦은 24년 회고록 - 책 "불안 세대"를 읽고 나서

전 글을 보시면 아시겠지만 1월부터 계약직 인턴 생활을 시작했습니다.

출근을 하고 일주일 정도는 도메인 지식 파악과, 기존 코드 분석만 하는데 시간을 사용했습니다.

 

여러 단계로 중첩된 연관관계를 가진 도메인과 비즈니스에 대한 이해를 해야했고, 처음 보는 DDD-Lite를 사용한 코드를 이해하기 위해 일주일 동안 인텔리제이를 붙잡고 있었습니다. 그러던 와중 어느날 갑자기 프론트 쪽 사무실에서 나와선 안되는 소리가 나왔습니다.

 

"…어? 뭐야 이거 왜 안돼”


어느날 파일 업로드가 안된다는 소식이 들려왔다.

프론트 쪽에서 갑자기 txt 파일이 요청에 들어가면 파일 업로드 API가 제대로 동작하지 않는다는 소식이 전해졌습니다.

현재 회사에선 파일 업로드를 위해 AWS-S3와 Presiengd-URL을 이용해 파일 업로드를 처리 중입니다.

 

https://docs.aws.amazon.com/ko_kr/AmazonS3/latest/userguide/using-presigned-url.html

 

미리 서명된 URL을 통해 객체 다운로드 및 업로드 - Amazon Simple Storage Service

임시 보안 인증 정보를 사용하여 미리 서명된 URL을 생성하면 보안 인증 정보가 만료되면 이 URL도 만료됩니다. 일반적으로 미리 서명된 URL은 URL을 만드는 데 사용한 자격 증명이 취소, 삭제 또는

docs.aws.amazon.com

(정확하게는 AWS S3가 아닌 회사 내부 NAS를 스토리지로 이용하고 있지만, 이 글에선 S3라고 생각해도 문제는 없습니다)

 

파일 업로드를 위해선 크게 다음과 같은 과정을 거칩니다.

  1. 프론트에서 업로드할 파일 정보를 넣어 백엔드 API에 요청
  2. 백엔드에선 해당 파일 정보를 DB에 저장한 후, 파일 정보에 맞는 presigned url 발급
  3. 프론트에서 해당 url을 이용해 S3 파일 업로드

문제는 2번째 과정인 presigned-url을 발급하는 과정에서 올바른 요청임에도 API가 에러를 뱉는 문제가 발생했다는 겁니다.

txt 확장자를 사용한 파일만 말이죠.

 

프론트에선 S3에 파일 업로드를 시도도 하기 전에 문제가 발생했으니 명백한 백엔드의 문제였습니다.

기존 코드에서 파일 업로드와 관련된 로직을 찾아 보기 시작했습니다. 그리고 얼마 안 가 바로 그 원인을 알 수 있었습니다.


하드코딩된 조건문에서 확장자가 누락됐다.

그 전에 파일 업로드 관련 비즈니스 로직에 대해 조금 더 자세히 살펴봅시다.

 

우리는 파일에 대한 정보를 DB에 저장하고 관리할 필요가 있습니다.

그래서 백엔드에선 presigned-url을 발급해주기 전, 해당 파일에 대한 정보를 DB에 저장해야 합니다.

이때 업로드하는 파일의 종류는 두 가지인데 이미지 파일문서 파일 이고, 상세한 요구사항은 다음과 같습니다.

  • 모든 파일은 공통적으로 (이름, 확장자) 정보를 가진다.
  • 이미지 파일의 경우 추가적으로 (해상도, 가로, 세로 길이) 정보를 가진다.
  • 두 파일은 확장자를 이용해 구분한다. jpg 확장자는 이미지 파일, 나머지 확장자 (txt, pdf, xml)들은 문서 파일로 구분한다.

이제 문제가 된 로직을 살펴봅시다. 파일 생성에 관련된 어플리케이션 서비스 로직입니다.

public class FileService {

    public File createFile(FileCreateRequest request) {
        FileExtension extension = request.extension();
        
        if(isFileTypeDocument(extension)){
            createDocumentFile(request);
        } else {
            createImageFile(request);
        }
    }
    
    // 문제의 로직
    private boolean isFileTypeDocument(FileExtension extension) {
        return extension == PDF || extension == XML;
    }
}

 

사용자 요청으로 들어온 파일 정보 중 확장자를 이용해 요청 파일이 이미지 파일 인지, 문서 파일 인지 구분합니다.
이때 확장자를 확인하는 로직은 isFileTypeDocument()를 통해서 진행됩니다.

그 이후 파일 타입에 알맞은 생성 로직을 호출합니다.

 

여러분들은 혹시 해당 함수가 반환하는 조건식에 TXT 타입 확장자가 빠져있다는 사실을 눈치채셨나요..?

isFileTypeDocument()에 들어있어야 할 TXT 확장자가 빠져있어 올바른 파일 타입으로 구분을 못해서 문제가 발생했던 것이었습니다.


문제는 이것뿐만이 아니다.

txt 파일 업로드 문제는 그렇게 위 함수 조건식 마지막에 TXT 조건을 추가하는 것으로 쉽게 해결했습니다.

그런데 이대로 넘어가기엔 뭔가 찜찜한 생각이 듭니다..

  1. 왜 저런 하드코딩된 조건식이 등장하게 된 걸까요?
  2. 그리고 왜 어플리케이션 서비스에 저 코드가 존재하는것이며, 저 로직 말고 비슷한 문제가 발생하는 로직이 또 없을까요?
    (당연히 있었습니다)
  3. 만약 새로운 확장자 타입이나 파일 종류가 추가되어 지원해야 한다면? (ex : mp4 동영상 파일 등)
    위에서 말한 문제가 생길 수 있는 함수들을 모두 찾아다니며 변경해야 하지 않을까요?

이 시점에서 저를 회사에 소개시켜준 A씨가 저에게 첫 임무를 줬습니다.

저 문제를 해결한 후, 파일 도메인 코드들을 알잘딱 하게 리팩토링해봐라.

 


 

기존 구현 뜯어보기 - JPA 상속 관계 매핑

이번에 문제가 된 로직을 리팩토링 하기 전에 파일 도메인이 어떻게 구현되어 있는지 확인해봅시다.

domain
├── file
│   ├── controller
│   ├── application
│   ├── dao
│   └── domain
│         ├── File.java 
│         ├── ImageFile.java
│         ├── DocumentFile.java
│         ├── FileExtension.java
│         └── ...
└── ...

 

파일 도메인에 들어가 domain 패키지를 열자 DDD 특유의 작은 애그리거트 루트가 아닌, 여러 종류의 파일들이 저를 반겼습니다.

한 번 자세한 구현을 보겠습니다.

 

@Entity @Getter
@Inheritance(strategy = InheritanceType.SINGLE_TABLE)
public class File {
    
    @Id @Column(name = "file_id")
    private Long id;
    private String name;
    
    @Enumerated(EnumType.STRING)
    private FileExtension extension;
}

public enum FileExtension {
    JPG(".jpg"), TXT("txt"), PDF("pdf"), XML("xml");

    private String value;
}
@Entity @Getter
public class ImageFile extends File {

    private double resolution;
    private int width;
    private int height;
}
@Entity
public class DocumentFile extends File {}

 

파일 도메인은 JPA 싱글 테이블 상속 전략을 이용해 구현하고 있었습니다.

파일 도메인의 애그리거트 루트는 File 이었고, File을 상속 받은 ImageFIle, DocumentFile 가 존재했습니다.

 

요구사항을 다시 한 번 생각해보면

  1. 두 종류의 파일 중 한 파일만 가지는 정보가 존재합니다. (이미지 파일은 해상도, 가로, 세로 길이를 저장할 필요가 있음)
  2. 하지만 두 파일을 시스템 내부에서만 구분하고 사용자는 구분하지 않습니다. (요청과 응답 양식은 이미지 파일, 문서 파일 모두 동일함)

위의 두 가지 요구사항 모두를 만족하기 위해 JPA 상속 관계 매핑을 사용해 구현한 것으로 보입니다.

 

파일과 이미지 파일, 문서 파일의 관계가 정확히 (is a / is a kind of) 관계를 만족하기 때문에
상속 관계 매핑을 이용하기로 한 선택은 괜찮은 것처럼 보입니다.

 

굳이 상속 관계 매핑을 사용하지 않고 구현할 방법은 없을까 싶지만

  • 파일 처리와 관련된 비즈니스 로직이 두 파일 모두 동일한데 둘을 다른 애그리거트로 보고 분리하는 것은 오히려 복잡성을 늘리고 중복 코드를 유발할 것으로 보이며
  • 상속 없이 단일 테이블과 단일 엔티티로 제작시 문서 파일에서 getWidth() 함수를 사용할 수 있다는 점이 어색하게 느껴지기도 합니다.

그러나 상속 관계를 활용하지 못하는 서비스 로직들

그러나 지금 어플리케이션 서비스 로직엔 앞서 설명한 문제를 일으켰던 조건문 외에도 상속 관계의 이점을 사용하지 못하는 코드들이 존재했습니다.

public class FileService {    
    
    // 파일 정보를 수정하는 메서드
    public void updateFile(File file, FileRequest reqeust){
        if (file instanceOf ImageFIle imageFile) {
            imageFile.update(request.name(),
                             request.extension(),
                             request.resolution(),
                             request.weight(),
                             request.height());
        }
        if (file instanceOf DocumentFile documentFile) {
            documentFile.update(request.name(), request.extension());
        } 
    }
}

 

instanceOf 를 이용해 해당 파일이 이미지 파일인지, 문서 파일인지 구분한 후 파일 타입별로 알맞은 함수를 호출하는 조건문입니다.

이러한 instanceOf를 활용하는 로직이 문제가 되는 이유는 많은 분들이 이미 아실거라고 생각합니다.

  • 객체지향 원칙을 정면으로 위배하는 코드 (OCP, SRP 등)
  • 외부 객체가 자식 객체들의 구현을 알고 있음
  • 또한 앞서 문제가 됐던 생성 로직에선, 확장자를 이용해 생성할 파일 타입을 구분했는데 여기선 파일 객체 타입으로 구분.
    즉 같은 역할을 하는 코드가 여기저기 분리되어 다른 방식으로 구현되어 있음 등등

사실 구글에 “instanceOf 사용을 지양하라” 라는 키워드로 검색을 해보면 다양한 블로그 글들을 볼 수 있습니다.
그리고 그러한 블로그 글들은 모두 위와 비슷한 이유를 근거로 대면서 객체지향적인 프로그래밍을 위해 instanceOf 사용을 지양하고 다형성을 사용하라고 이야기 합니다.


다형성을 사용해보려했더니.. 어?

instanceOf 대신 다형성을 활용하기 위해 수정 로직을 다음과 같이 수정해보겠습니다.

// 파일 정보를 수정하는 메서드
public void updateFile(File file, FileRequest reqeust){
    file.update(request.name(), request.extension(), ???); //문제 발생
}

 

“어 근데 다형성을 사용할 수가 없는데?”

 

생각해보면 파일 타입 별로 응답 객체 생성과 수정 메서드에 들어가는 값이 다르다는 것을 알 수 있습니다.

이미지 파일은 문서 파일과 다르게, 수정 시에 가로, 세로, 해상도 값을 필요로 합니다.

 

이러한 상황에서 다형성을 활용하려면 다음과 같이 부모 클래스에서
들어올 수 있는 모든 값을 파라미터로 하는 update() 추상 메서드를 작성해야 합니다.

public class File {

    private Long id;
    private String name;
    private FileExtension extension;
    
    // 추상 메서드 사용
    public abstract void update(String name, 
                                FileExtension extension, 
                                double resolution, 
                                int width, 
                                int height) {}
}

public class DocumentFile extends File {

    @Override // 사용하지도 않을 width, heidhgt를 파라미터로 받는 문제
    public void update(String name, FileExtension extension, double resolution, int width, int height){
        this.name = name;
        this.extension = extension;
    }
}

public class ImageFile extends File {
    private double resolution;
    private int weight;
    private int height;
    
    @Override
    public void update(String name, FileExtension extension, double resolution, int width, int height){
        this.name = name;
        this.extension = extension;
        this.resolution = resolution;
        this.weight = width;
        this.height = height;
    }
}

 

그러나 이러한 로직은 향후 다양한 문제를 발생시킬 수 있습니다.

  1. 부모 클래스가 자식 클래스의 구현에 의존합니다. 새로운 변수를 가지는 파일 타입이 추가된다면 부모 클래스의 update 메서드와, 이를 구현한 자식 클래스의 메서드들도 모두 찾아가서 바꿔야만 합니다.
  2. 사용자는 인자로 들어간 값들로 파일 내부 정보를 바꿀 것을 예상하는데, Document 파일 구현을 들어가봐야지만 인자로 받은 값을 무시한다는 사실을 알 수 있습니다. 메서드 이름만 보고는 사용자가 어떻게 동작할지 예측할 수 없는 상황이 오게 됩니다.

결국 해답은, 서브타입 별로 update 함수를 구현한 후, 서비스 코드에서 타입별 캐스팅을 한 후 사용해야 합니다. 이 말은 즉 서비스에서 instanceOf를 완전히 제거하기는 것이 불가능하다는 것입니다.

사실 파일 도메인 내부에 isDocumentFile()과 같이 파일 타입을 체크하는 함수를 만듦으로서 표면적으로 instnaceOf를 제거하는 것은 가능합니다.
그러나 이는 파일 타입 조회를 instanceOf로 하냐, 내부 속성을 이용하냐의 차이일 뿐, 서비스에서 파일의 서브타입과 구현을 알아야 한다는 instanceOf의 단점을 해결할 수 없습니다.


다형성은 만능 해결책이 될 수 없다.

앞서 이야기한 대부분의 블로그 글에서 instanceOf 에 대해 다루는 이야기를 보면,
코드를 보다가 instnaceOf를 보자마자 속이 뒤집어지고, 알 수 없는 무언가가 끓어오르며, 당장 저 코드를 작성한 놈을 잡아다 멍석에 말아 두들겨 패고 소금을 뿌려 쫓아낸 다음, 다형성을 사용하라는 식으로 이야기합니다. (사실 그 정도는 아니다)

 

그런데 instanceOf를 무슨일이 있어도 사용하면 안되는 걸까요?

제임스 고슬링이 바보도 아니고, 의미 없는 기능을 우리를 괴롭히려고 만들었을리가 없습니다.

 

우리 요구사항을 충족하기 위해선 instanceOf나 그와 같은 역할을 하는 로직이 반드시 서비스에 존재해야 합니다.

그러나 instanceOf를 사용을 지양해야 하는 이유 역시 앞서 설명했듯이 명확합니다.

 

잠깐.. 그럼 애초에 이 상황에서 상속을 쓰는게 맞는 걸까요?

 

“instanceOf를 반드시 사용해야 하는 상황이 온다면, 그건 사실 상속을 활용하면 안되는 상황에서 상속을 사용중이라는 신호로 봐야하는 것 아닐까?” 라는 생각이 들기 시작했습니다.

 


우리는 상속 관계 매핑을 지양해야 하는가?

 

상속관계 매핑을 지양해야 하는가? - 인프런 | 커뮤니티 질문&답변

누구나 함께하는 인프런 커뮤니티. 모르면 묻고, 해답을 찾아보세요.

www.inflearn.com

 

검색을 해보던 중 JPA 대가 영한 선생님이 인프런 질문에 답변하신 내용을 찾아 가져왔습니다.

  • 객체의 상속관계를 활용할 것인가? (객체)
  • 아니면 객체 내부에 타입을 두고 해당 타입으로 구분할 것인가 (자료구조)

의 문제로 보라는 힌트를 받았습니다. 클린 코드 6장의 내용을 조금 더 살펴보겠습니다.

 

객체는 동작을 공개하고 자료를 숨긴다. 그래서 기존 동작을 변경하지 않으면서 새 객체 타입을 추가하기 쉬운 반면, 기존 객체에 새 동작을 추가하기는 어렵다.

자료 구조는 별다른 동작 없이 자료를 노출한다. 그래서 기존 자료 구조에 새 동작을 추가하기는 쉬우나, 기존 함수에 새 자료 구조를 추가하기는 어렵다.

 

아무래도 고민하던 내용과 비슷한 것 같습니다.

그렇다면“우리 프로젝트와 같은 상황에서 상속 관계 매핑을 사용하는것이 맞는가?” 를 고민해봤을때

  • 객체의 상속관계(다형성)을 이용할 것인가
  • 객체 내부에 타입을 두고 구분할 것인가 (실질적 instanceOf 사용)

를 결정해야하고 전자는 엔티티를 객체, 후자는 자료구조로 보는 관점입니다.

그리고 우리는 다형성을 사용할 수 없는 상황이므로 후자를 선택해야만 합니다.


JPA 엔티티를 자료구조로 본다? 그럼 DDD는?

// 디미터 법칙을 위반하는 것처럼 보이는 코드
final String outputDir = ctxt.options.scratchDir.absolutePath;

클린 코드에선 자료구조는 해당 메서드 내부의 모든 내용을 자유롭게 접근할 수 있어야 하며 비즈니스 규칙 메서드를 추가해선 안된다고 이야기합니다. 디미터 법칙과 이를 어기는 "기차 충돌" 코드도 객체라면 문제가 있지만, 자료구조에선 용인해도 된다고 이야기하기도 합니다.

 

이러한 엔티티를 자료구조로 보는 관점은 DDD의 전술적 패턴과 부유한 도메인 모델을 전적으로 부정하는 일이 아닌가 하는 생각이 듭니다.

 

그런데 DDD는 항상 적용해야 하는 절대적인 개념인가라는 물음에 대한 대답은 “아니오” 라고 단언할 수 있습니다.

도메인 주도 개발 시작하기에서도 이런 이야기를 합니다.

비즈니스 로직이 간단한 CRUD를 벗어나지 않으며, 다른 도메인과 연관관계가 복잡하지 않다면, 때로는 트랜잭션 스크립트 패턴, 고전적인 컨트롤러 서비스 도메인 패턴을 사용하는 것이 유지보수에 더 유리할 수 있다.

 

우리의 파일 도메인은 어떨까요? 제 생각에는

비즈니스 로직은 CRUD 로직에 presigned-url을 발급하는 로직 정도만 추가되어 있으며,
다른 도메인과 연결될 가능성도 매우 적은 간단한 도메인입니다.

 

이런 경우, DDD에 매몰되어 객체로만 보기보단, 조금 더 유연한 사고가 필요할 때입니다.


 

이제 알잘딱하게 리팩토링해보자

1. 확장자를 이용해서 파일 타입을 알 수 있도록 하기

@Getter
@RequiredArgsConstructor
public enum FileExtension {
    JPG("jpg", IMAGE),
    XML("xml", DOCUMENT),
    PDF("pdf", DOCUMENT),
    TXT("txt", DOCUMENT);

    private final String value;
    private final FileType fileType;
}

public enum FileType {
    DOCUMENT, IMAGE;
}

우선 위와 같이 확장자를 이용해서 파일 타입을 알 수 있도록 하기 위해서
확장자 enum 내부에 파일 타입을 분류할 수 있는 enum 항목을 추가했습니다.

 

사실 파일 타입은 확장자만으로 결정되며, 같은 확장자가 여러 파일 타입을 가질 확률도 매우 적어 이렇게 변경하기로 결정했습니다.

 

이제 글의 맨처음 문제가 발생했던 파일 생성 전 구분 로직, instanceOf를 사용하는 로직들을 다음과 같이 바꿀 수 있습니다.

public class FileService {
    
    // == 변경 전 ==
    private File createFile(FileCreateRequest request) {
        FileExtension extension = request.extension();
        
        if(isFileTypeDocument(extension)){
            return createDocumentFile(request);
        } else {
            return createImageFile(request);
        }
    }
    // 문제의 로직
    private boolean isFileTypeDocument(FileExtension extension) {
        return extension == PDF || extension == XML;
    }
    
    // == 변경 후 ==  
    public File createFile(FileCreateRequest request) {
        FileExtension extension = request.extension();
        
        if (extension.getFileType() == IMAGE) {
            return ImageFile.create(
                             fileName, 
                             extension, 
                             resolution, 
                             width, 
                             height);
        } 
        else if (extension.getFileType() == DOCUMENT) {
            return DocumentFile.create(fileName, extension);
        } 
        else {
            throw new Exception("Invalid FileType");
        }
    }

    //변경 전
    public void updateFile(File file, FileRequest reqeust){
        if (file instanceOf ImageFIle imageFile) {
            imageFile.update(request.name(),
                             request.extension(),
                             request.resolution(),
                             request.weight(),
                             request.height());
        }
        if (file instanceOf DocumentFile documentFile) {
            documentFile.update(request.name(), request.extension());
        } 
    }
    
    // 변경 후
    public void updateFile(File file, FileRequest reqeust){
        FileExtension extension
        
        if (extension.getFileType() == IMAGE) {
            ImageFile imageFile = (ImageFile) file;
            imageFile.update(request.name(),
                             request.extension(),
                             request.resolution(),
                             request.weight(),
                             request.height());
        }
        if (extension.getFileType() == DOCUMENT) {
            DocumentFile documentFile = (DocumentFile) file;
            documentFile.update(request.name(), request.extension());
        } 
    }
}

이제 새로운 파일 확장자가 추가되더라도, 파일 서비스는 변경할 필요 없이 FileExtension enum만 추가하면 됩니다.

 

2. 변경에 취약한 부분을 몰아넣기

사실 위와 같은 코드가 FileService의 비즈니스 로직의 전부는 아닙니다.

Presiend-URL을 받아오는 로직도 존재해야하고 파일 조회, 삭제와 같은 로직도 FileService 내부에 존재합니다.

 

사실 이 글의 예시 코드는 원활한 설명을 위해, 실제 코드를 참고하여 새롭게 만들어 온 것에 가깝습니다.

실제 코드는 조금 더 다양한 역할을 수행하고 있었습니다. 그런데 만약 새로운 파일 타입이 추가된다면..?

 

위의 생성, 수정 로직의 if문은 추가된 파일 타입에 맞춰 변경되어야 합니다.

그리고 하나씩 수정하는 과정에서 놓치는 부분이 있다면 역시 문제가 됩니다.

 

이걸 해결하는 방법은 여러가지가 있겠지만, 변경에 취약한 부분만 따로 몰아넣는 것도 좋은 해결책입니다.

그리고 사실 조건에 따라 알맞은 객체를 생성하여 반환하는 좋은 방법을 모두 알고 있습니다.

바로 팩토리 클래스를 활용하는 것입니다.

public class FileFactory {

    public File createFile(String fileName, FileExtension extension, double resolution, int width, int height) {
        
        if (extension.getFileType() == FileType.IMAGE) {
            return ImageFile.create(
                             fileName, 
                             extension, 
                             resolution, 
                             width, 
                             height);
        } 
        else if (extension.getFileType() == FileType.DOCUMENT) {
            return DocumentFile.create(fileName, extension);
        } 
        else {
            throw new Exception("Invalid FileType");
        }
    }
}

 

렇게 팩토리 클래스를 도메인 서비스 처럼 만들어 관리한다면,
파일 타입 추가에 취약한 코드들을 어플리케이션 서비스에서 분리할 수 있습니다.

 

3. 상속 관계 매핑 해제하기

그리고 이렇게 파일 도메인 내부의 Extension을 이용해 파일 타입을 구분한다면 더 이상 상속을 사용해야만 하는 이유는 없어집니다.

어차피 단일 테이블을 사용해 데이터를 저장하니, 다음과 같이 합성을 이용하도록 변경해볼 수도 있겠습니다.

public class File {

    private Long id;
    private String name;
    private FileExtension extension;
    private ImageData imageData;
    ...
    
    public void updateDocumentFile(String name, FileExtension extension){
        ....
    }
    
    public void updateImageFile(String name, FileExtension extension, ImageData imageData){
        ....
    }
}

public class ImageProperties {
    
    private double resolution;
    private int width;
    private int height;
}

 

그러나 여기까지 진행하진 않았습니다. 그 이유는

  1. 상속 관계의 엔티티 정의가 비즈니스 규칙을 명료하게 제공한다.
    • 파일 타입에는 이미지 파일과 문서 파일이 있고.. 이미지 파일은 어떤 정보를 추가로 가지고… 와 같은 정보를 엔티티 정의를 본다면 명확하게 파악할 수 있습니다.
  2. 타입 캐스팅을 통해 혹시 모를 엔티티의 잘못된 사용을 예방할 수 있다.
    • 문서 파일에서 해상도를 호출하려 하면 문제가 발생해야 합니다. 상속을 통해 문서 파일과 이미지 파일을 분리하면, 위와 같은 상황을 막을 수 있습니다.
  3. 잘 사용하고 있던 DB 테이블 구조 변경에 대한 부담
    • 상속을 사용하지 않는다면 DB 테이블에서 더이상 dtype 컬럼을 사용할 필요가 없습니다. 그러나 “이미 잘 사용하고 있던 DB 테이블까지 바꾸면서 까지 고쳐야할 문제인가?” 는 아니라고 생각했습니다.

 


 

결론 & 후기

상속관계 매핑을 사용하던 중 instancOf를 사용할 수 밖에 없는 상황이 생겼다면 “엔티티 내부에 타입(Enum FileType)을 두고, 엔티티를 자료구조로 사용해 외부에서 타입을 읽고 사용하는 방법도 고려해봐야 한다.” 라는 결론입니다.

 

간단한 리팩토링이었지만 그 과정에서 “instanceOf 사용을 지양해야 하는가?”와 같이 블로그만 읽고 대충 알던 지식들을 제대로 공부해보고 상황에 맞춰 적용하기 위해 고민해보는 좋은 경험을 얻었습니다.
또한 "은탄환은 없다"와 가장 좋은 설계란 내 상황에 가장 알맞은 설계이다. 라는 자주 잊는 진리를 다시 한 번 깨닫게 됐습니다.

 

사실 어그로를 잔뜩 끈 서론에 비해 결론은 보잘것 없으며, 결론에 비해 결과물은 더 애매한 리팩토링으로 끝났습니다.

상속 구조는 아직도 사용 중이며, 서비스 코드와 팩토리 클래스엔 중복된 코드가 존재합니다.

 

리팩토링을 하던 도중 고민하던 저에게 A씨가 WET 원칙과 DRY 원칙을 반대하는 사람들의 의견에 대해 이야기해줬습니다. 

“관리가 가능한 중복 코드는 너무 두려워 할 필요는 없다” 라는 이야기로 들렸습니다.

https://news.hada.io/topic?id=15102

 

코드를 성급하게 DRY하지 마세요 | GeekNews

코드 중복 제거를 너무 일찍 하지 말아야 하는 이유DRY(Don't Repeat Yourself) 원칙을 너무 엄격하게 적용하면 "Premature" 추상화를 유발하여 미래의 변경이 더 복잡해질 수 있음중복이 진짜로 불필요한

news.hada.io

 

 

비슷한 의미로 영한 선생님은 "어느날 고민 많은 주니어 개발자가 찾아왔다" 라는 세미나에서 
“추상화도 비용이다”라는 이야기를 하셨습니다.

 

객체지향 원칙은 중요합니다.

하지만 변경의 범위를 한정하고, 변경에 취약한 코드를 한쪽으로 몰아넣는 선에서 중복 코드와 의존관계를 잘 관리할 수 있다면
성급한 추상화보다 좋은 해결책이 될 수 있습니다.

 

완벽한 객체지향 코드를 만드는 추상화의 비용이 때로는 중복 코드와 절차지향 코드를 사용하는 것보다 클 때도 있다는 사실을 잊지 않아야겠습니다.

 

분별있는 프로그래머는 모든 것이 객체라는 생각이 미신임을 잘 안다.
때로는 단순한 자료구조와 절차적인 코드가 가장 적합한 상황도 있다. 
-클린 코드-

 

 

 

잘못된 내용을 보시거나 다른 견해가 있다면 언제든 댓글 부탁드립니다.

  • 여담으로 저 리팩토링 이후 약 한 달 뒤 확장자를 추가해야 하는 상황이 생겼습니다.
    enum 하나 추가하는 것으로 다른 코드 하나도 안건드리고 깔끔하게 확장자 추가에도 성공했는데 기분이 상당히 좋았습니다.

좋은 설계란 무엇인가?

객체지향 프로그래밍이란 무엇인가?

어떻게 해야 유연하고 재사용성이 높은 코드를 짤 수 있을까?

라는 질문을 하고 스스로 해답을 찾기 위해 노력해봤다.

 

하지만 이러한 고민을 기존에 하던 스프링 부트&JPA 프로젝트에 적용하기는 뭔가 거리가 멀어 보였다.

 

Controller, Service, Repository 로 이루어진 계층형 구조

구글에 검색하면 끝도 없이 보이는 주문 시스템, 블로그 글 예제

 

내가 진행하던 프로젝트에서 겪었던 어려움과 고민과는 거리가 먼 예제들만 나왔으며,

어떻게 해야 해결할 수 있는지 알 수 없었다.

사실 지금도 잘 모르겠다.

 

다만 문제와 해결과정, 그 과정에서 한 생각을 글로 남겨보려고 한다.

 

1. 복잡한 비즈니스 로직과 추가되는 요구사항 속에서 단일 책임 원칙 지키기

단일 책임 원칙을 어기는 객체가 생긴다면 한가지 변경사항이 생긴다면

관련된 코드 전부를 찾아서 고쳐야한다.

이는 복잡하고, 귀찮고, 버그의 원인이 되며, 리팩토링과 기능추가를 하기 힘들게 하는 원인이 된다.

 

예제를 보기 전에 프로젝트의 ERD를 보고 도메인 구조를 간단히 살펴보고 가자.

예제에 나올 부분만 가져와 봤다.

  • User는 Workspace와 다대다(N:M) 관계를 가진다.
  • User는 Schedule과도 다대다(N:M) 관계를 가진다.
  • Workspace는 Schedule과 일대다(1:N) 관계를 가진다.
  • UserWorkspace와 UserSchedule은 다대다 관계를 연결하기 위한 중간 테이블의 역할을 한다.

Schedule 도메인과 Schedule의 비즈니스 로직을 담당하는 ScheduleService를 간략히 살펴보자.

//Schedule.java
@Entity
@Getter
public class Schedule{
    @Id @GeneratedValue
    @Column(name = "schedule_id")
    private Long id;
    
    private String name;
    
    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "workspace_id")
    private Workspace workspace;
    
    @OneToMany(mappedBy="schedule", cascade = CascadeType.ALL, orphanRemoval = true)
    private List<UserSchedule> userSchedules;
}


//UserSchedule.java
@Entity
public class UserSchedule{
    @Id @GeneratedValue
    @Column(name = "user_schedule_id")
    private Long id;
    
    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "schedule_id")
    private Schedule schedule;
    
    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "user_id")
    private User user;
    
    public UserSchedule(User user, Schedule schedule){
        this.user = user;
        this.schedule = schedule;
    }
}
///ScheduleService.java
@Service
@RequiredArgsConstructor
public class ScheduleService{
    
    private final WorkspaceRepository workspaceRepository;
    private final ScheduleRepository scheduleRepository;
    private final UserRepository userRepository;

    @Transactional
    public void addUser(Long scheduleId, String userId){
    	Schedule schedule = scheduleRepository.findById(scheduleId);
        
        User user = userRepository.findById(userId);
        
        schedule.getUserSchedules().add(new UserSchedule(user, schedule));
    }
    ...
}

아무런 문제없이 잘 돌아가고, Schedule, Repository의 역할도 분리되었으며 괜찮게 작성한 것 같다.

그런데 비즈니스 로직에 요구사항이 몇가지 추가된다면?

 

"Schedule에 참여하는 User는 Workspace에 속한 유저만 가능하도록 해주세요"

비즈니스 로직은 서비스 레이어에서 담당한다.

스케줄에 유저 추가, 삭제, 스케줄 생성은 당연히 스케줄에 관련된 중요한 비즈니스 로직이다.

일단 스케줄 서비스에 검증 로직을 추가해보자.

@Service
@RequiredArgsConstructor
public class ScheduleService{
    
    private final WorkspaceRepository workspaceRepository;
    private final ScheduleRepository scheduleRepository;
    private final UserRepository userRepository;

    @Transactional
    public void addUser(Long scheduleId, String userId){
    	Schedule schedule = scheduleRepository.findById(scheduleId);
       
        User user = userRepository.findById(userId);
		
        // <== 검증 로직 추가 ==>
        Workspace workspace = schedule.getWorksapce();
        
        List<User> workspaceUserList = workspace.getUserWorkspaces().stream()
                                                                     .map(uw -> uw.getUser())
                                                                     .Collectors.toList();
        
        if (!workspaceUserList.contains(user)){
        	throw new IllegalStateException("워크스페이스에 존재하지 않는 유저입니다");
        }
        
        schedule.getUserSchedules().add(new UserSchedule(user, schedule));
    }
}

 

서비스 레이어의 메서드를 수정해 비즈니스 로직을 처리하도록 했다.

그런데 만약 또 다른 요구사항이 등장한다면?

 

"한번에 여러명의 유저를 추가할 수 있도록 해주세요."

이 요구사항을 처리할 수 있는 방법은 여러가지이다.

  • 컨트롤러 단에서 유저 서비스를 이용해 유저 리스트를 생성한 후, 스케줄서비스의 addUser() 메서드 반복 실행시키기
  • 스케줄 서비스에서 유저 리스트 생성, 유저 추가 로직 둘 다 수행하게 하기

사실 전자가 더 좋은 방법처럼 보인다.

스케줄 서비스에서 유저 리포지토리를 활용해 유저 리스트를 만드는 것이 그리 좋아보이진 않는다.

그러나 컨트롤러에서 반복문을 돌리는 로직을 넣는다면 컨트롤러에 비즈니스 로직이 들어간다고 볼 수 있다.

 

만약 특정 상황에서만 스케줄에만 유저를 추가할 수 있는 요구사항이 생긴다면?

(한번에 추가할 수 있는 유저 수의 제한 등이 생긴다면?)

예시가 조금 억지같긴 하지만, 컨트롤러는 url 매핑, 입력처리와 응답 값 생성에 집중해야하므로 후자를 선택했다.

@Service
@RequiredArgsConstructor
public class ScheduleService{
	
    ...
    
    @Transactional
    public void addUsers(Long scheduleId, List<Long> userIdList){
    	Schedule schedule = scheduleRepository.findById(scheduleId);
       
        List<User> userList = userRepository.findByIdIn(userIdList);
		
        // <== 검증 로직 추가 ==>
        Workspace workspace = schedule.getWorksapce();
        
        List<User> workspaceUserList = workspace.getUserWorkspaces().stream()
                                                                     .map(uw -> uw.getUser())
                                                                     .Collectors.toList();
        
        for(User user : userList){
            if (!workspaceUserList.contains(user)){
        	    throw new IllegalStateException("워크스페이스에 존재하지 않는 유저입니다");
            }
            schedule.getUserSchedules().add(new UserSchedule(user, schedule));
        }
    }
}

 

 

서비스에서 다음과 같이 유저 아이디를 받아서 유저 리스트를 조회한 후

반복문을 통해 검증 로직을 수행시키고 유저를 추가하는 addUsers()메서드를 생성했다.

 

검증 로직이 addUser(), addUsers() 두 개의 메서드에서 반복된다.

DRY 원칙을 지켜야하니까 메서드로 분리해보자.

@Service
@RequiredArgsConstructor
public class ScheduleService{
    ...
    
    @Transactional
    public void addUser(Long scheduleId, String userId){
    	Schedule schedule = scheduleRepository.findById(scheduleId);
        User user = userRepository.findById(userId);
        Workspace workspace = schedule.getWorksapce();
        
        checkUserInWorkspace(workspace, user);
        addUserInSchedule(schedule,user);
    }
    @Transactional
    public void addUsers(Long scheduleId, List<Long> userIdList){
    	Schedule schedule = scheduleRepository.findById(scheduleId);
        Workspace workspace = schedule.getWorksapce();
        List<User> userList = userRepository.findByIdIn(userIdList);
		
        for(User user : userList){
            checkUserInWorkspace(workspace, user);
            addUserInSchedule(schedule, user);
        }
    }
    
    private void checkUserInWorkspace(Workspace workspace, User user){
        List<User> workspaceUserList = workspace.getUserWorkspaces().stream()
                                                                     .map(uw -> uw.getUser())
                                                                     .Collectors.toList();
        if (!workspaceUserList.contains(user)){
        	throw new IllegalStateException("워크스페이스에 존재하지 않는 유저입니다");
        }
    }
    
    private void addUserInSchedule(Schedule schedule, User user){
        schedule.getUserSchedules().add(new UserSchedule(user, schedule));
    }
}

워크스페이스에 유저가 존재하는지 검증하는 로직을 메서드로 만들고,

스케줄에 유저를 추가하는 로직도 메서드로 만들어 분리했다.

 

중복되는 코드를 줄였고, 재사용성도 높였으며, 유지보수하기도 쉬워보인다.

 

그러나 한가지 문제점이 생기게 된다.

 

워크스페이스에 관련된 로직이 스케줄 서비스에 들어가 있다.

이제 스케줄 서비스는 워크스페이스의 변화에 영향을 받는다.

 

ScheduleService는 이제 단일 책임 원칙을 위반하는 코드가 됐다.

스케줄 서비스가 워크스페이스 도메인에도 강하게 결합된 것이다.

 

만약 워크스페이스에 변경사항이 생긴다면? (그럴 일은 없지만, 다대다 관계를 그냥 ManyToMany 필드를 써버린다면?)

ScheduleService에서 워크스페이스를 사용하는 메서드도 찾아서 변경해야 한다.

 

그렇다면 워크스페이스 서비스에서 유저 확인 로직을 구현한 후 ScheduleService에서 사용하면?

 

스케줄 서비스가 워크스페이스 서비스를 알고 있어야한다.

서비스에서 다른 서비스를 참조한다?

God Class, 책임이 집중되는 서비스가 생겨버리는 위험이 생길 수 있다.

 

어떻게 해야할까? 

 

"묻지말고 시켜라 (Tell, Don't Ask)" 

사실 위의 구현은 전부 "묻지말고 시켜라 (Tell, Don't Ask)" 원칙에 정반대로 위배되는 행위들이다.

private void checkUserInWorkspace(Workspace workspace, User user){
    List<User> workspaceUserList = workspace.getUserWorkspaces().stream()
                                                                  .map(uw -> uw.getUser())
                                                                  .Collectors.toList();
    if (!workspaceUserList.contains(user)){
        throw new IllegalStateException("워크스페이스에 존재하지 않는 유저입니다");
    }
}

private void addUserInSchedule(Schedule schedule, User user){
    schedule.getUserSchedules().add(new UserSchedule(user, schedule));
}

 

객체지향적인 설계를 하려면 어떻게 해야할까? 라는 질문에 책임 주도 설계 방법이 등장한다.

먼저 적절한 메세지를 정하라.

메세지를 처리할 정보를 가장 잘 아는 객체에게 위임해라.

 

워크스페이스에 유저가 존재하는지 확인하라.

스케줄에 유저를 추가하라.

라는 메세지를 가장 잘 처리할 수 있는 알맞은 객체는 무엇일까?

 

사실 위의 비즈니스 로직들을 처리할 가장 적절한 객체는 도메인 객체이다.

워크스페이스에 어떤 유저가 연결되어있는지 가장 잘 아는 객체는 Workspace 도메인 자체이다.

스케줄에 특정 유저를 추가하기 위한 가장 좋은 방법은 그냥 Schedule 객체에게 맡기면 된다.

 

이제 서비스 객체가 아닌 도메인 객체가 비즈니스 로직을 추가해보자.

2. Service 레이어가 비즈니스 로직을 처리하는 곳 아닌가요?  -  올바른 비즈니스 로직의 위치는 어디인가

 

https://www.inflearn.com/questions/250279/service%EC%99%80-entity-%EB%B9%84%EC%A6%88%EB%8B%88%EC%8A%A4-%EB%A1%9C%EC%A7%81%EC%97%90-%EA%B4%80%ED%95%B4

 

Service와 Entity 비즈니스 로직에 관해 - 인프런

안녕하세요.DDD Start라는 책을 함께 읽으며 영한님의 강좌를 다시 한번 보고 있습니다.그러던 도중 과연 어디까지 비즈니스 로직으로 보아야할까에 대한 의문점이 생겼습니다.책에서는 도메인(

www.inflearn.com

반드시 서비스 레이어에서 비즈니스 로직을 처리해야만 하는 이유는 없다.

 

  • 서비스 레이어에서 비즈니스 로직을 처리하는 것이 결합도를 높이는 상황
  • 하나의 서비스 객체로는 해결하기 힘든 복잡한 비즈니스 로직

위와 같은 상황에선 도메인에 비즈니스 로직을 넣는 방법을 생각해볼 수 있다.

 

이미 영한 선생님은 JPA 강의에서 도메인에 비즈니스 로직을 넣음으로서 힌트를 주셨다.

(연관관계 매핑 메서드를 도메인에 만들어 둔것)

 

이번엔 우리의 도메인 객체(엔티티)에 해당 비즈니스 로직을 넣어보자.

//Workspace.java
@Entity
@Getter
public class Workspace{
    @Id @GeneratedValue
    @Column(name = "schedule_id")
    private Long id;
    
    private String name;
    
    @OneToMany(mappedBy="workspace", cascade = CascadeType.ALL, orphanRemoval = true)
    private List<UserWorkspace> userWorkspaces;
    
    //<== 비즈니스 로직 ==>
    public boolean hasUser(User user){
    	List<User> userList = userWorkspaces.stream()
       	                                      .map(uw -> uw.getUser())
                                              .Collectors.toList();
        return userList.contains(user);
    }
}
//Schedule.java
@Entity
@Getter
public class Schedule{
    @Id @GeneratedValue
    @Column(name = "schedule_id")
    private Long id;
    
    private String name;
    
    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "workspace_id")
    private Workspace workspace;
    
    @OneToMany(mappedBy="schedule", cascade = CascadeType.ALL, orphanRemoval = true)
    private List<UserSchedule> userSchedules;
    
    //<== 비즈니스 로직 ==>
    public void addUser(User user){
    	if(workspace.hasUser(user)){
            throw new IllegalStateException("워크스페이스에 존재하지 않는 유저입니다.");
        }
    	userSchedules.add(new UserWorkspace(this,user));
    }
}

Workspace 도메인 객체에 특정 유저가 속하는지 판별하는 hasUser() 메서드를 만들었다.

그리고 Schedule 도메인 객체에 유저를 스케줄에 추가하는 addUser() 메서드를 만들었다.

 

이제 ScheduleService의 로직을 바꿔보자.

@Service
@RequiredArgsConstructor
public class ScheduleService{
    ...
    
    @Transactional
    public void addUser(Long scheduleId, String userId){
    	Schedule schedule = scheduleRepository.findById(scheduleId);
        User user = userRepository.findById(userId);
        
        schedule.addUser(user);
    }
    @Transactional
    public void addUsers(Long scheduleId, List<Long> userIdList){
    	Schedule schedule = scheduleRepository.findById(scheduleId);
        userRepository.findByIdIn(userIdList).stream()
            .forEach(user -> schedule.addUser(user));
    }
}

이렇게 도메인 객체에 비즈니스 로직을 위임하게 되면 서비스 레이어는 얇고 단순해진다.

또한 더 이상 상관없는 도메인의 변경이 서비스의 변경에 영향을 주지 않는다. (Workspace에 의존하지 않는다)

검증,제약사항 등 비즈니스 로직을 추가할때에도 도메인 객체만 수정하면 된다.

 

3.  서비스와 도메인에 애매하게 걸쳐있는 비즈니스 로직 - 도메인 객체는 어디까지 권한을 가져도 되는가, 객체와 자료구조

https://www.inflearn.com/questions/117315/%EB%B9%84%EC%A7%80%EB%8B%88%EC%8A%A4-%EB%A1%9C%EC%A7%81%EA%B5%AC%ED%98%84-entity-vs-service

 

비지니스 로직구현 Entity VS Service - 인프런

안녕하세요 강사님!이번 강의에서는Item.class에 다음과 같이 해당 변수에 대한 접근은 해당 entity에서 작성하여 주셨는데요,/* * 재고 수량 증가 */public void addStock(int quantity){ this.stockQuantity += q...

www.inflearn.com

그렇다면 모든 비즈니스 로직을 도메인에 넣어도 되는가?

도메인 객체의 권한은 어디까지인가에 대해서 고민해 볼 수 있다.

 

영한 선생님이 클린코드 6장(객체와 자료구조)을 참고해보라고 하셔서 읽고 왔다.

 

클래스의 인스턴스를 객체로 보거나 자료구조로 보는지에 따라 무엇이 달라지는지 설명하고 있다.

 

도메인 객체(엔티티)는 객체로 봐야하는가 자료구조로 봐야하는가? 라는 질문에

나는 객체로 보기로 했다.

 

엔티티는 단순히 값을 가지기만 하는 자료구조로 본다면,

복잡한 비즈니스 로직이 추가되면 서비스 레이어가 과부화되고 단일 책임 원칙을 지키기 힘들어진다.

 

워크스페이스 검증은 워크스페이스가 담당하는게 적합하다.

다른 곳에서 처리하는 것은 결합도를 높인다.

가능한 대부분의 비즈니스 로직을 도메인으로 옮겨왔다.

@Entity
@Getter 
public class Workspace{

    @Id @GeneratedValue
    @Column(name = "workspace_id")
    private Long id;
    
    @Column(unique = true, nullable = false)
    private String name;
    
    @Column(nullable = false)
    private String profile;
    
    // Workspace가 UserWorkspace 영속성 관리
    @OneToMany(mappedBy="workspace", cascade = CascadeType.ALL, orphanRemoval = true)
    private List<UserWorkspace> userWorkspaces = new ArrayList<>();
    
    // 연관관계 삭제용
    @OneToMany(mappedBy="workspace", orphanRemoval = true)
    private List<Schedule> schedules = new ArrayList<>();

    // <== 비즈니스 로직 == > //
    //유저 추가
    public void addUser(User user){
        if (this.hasUser(user)){
            throw new IllegalStateException("이미 존재하는 user 입니다");
        }
        
        UserWorkspace userWorkspace = UserWorkspace.builder()
                                                    .user(user)
                                                    .workspace(this)
                                                    .userRole(UserRole.USER)
                                                    .build();
        this.userWorkspaces.add(userWorkspace);
    }
    //유저 삭제
    public void removeUser(User user){
        if (!this.hasUser(user)){
            throw new IllegalStateException("존재하지 않는 user 입니다");
        }
        
        this.userWorkspaces.removeIf(uw -> uw.getUser().equals(user));
    }
    //유저 권한 변경
    public void giveAuthority(User user, UserRole userRole){
        if (!this.hasUser(user)){
            throw new IllegalStateException("존재하지 않는 user 입니다");
        }
        
        this.userWorkspaces.stream()
                            .filter(uw -> uw.getUser().equals(user))
                            .forEach(uw -> uw.setUserRole(userRole));
    }
    // 수정 로직
    public void updateWorkspace(String name, String profile){
        this.name = name;
        this.profile = profile;
    }
    // 유저 검증
    public boolean hasUser(User user){
        return (this.userWorkspaces.stream()
                                    .filter(uw -> uw.getUser().equals(user))
                                    .count() == 1);
    }
}

 

그러나 엔티티 혼자서는 해결하지 못하는 로직도 존재한다.

DB에 저장된 값을 조회해야 하는 경우, 즉 리포지토리를 확인해야 하는 로직의 경우(아이디를 이용해 조회가 필요한 경우)

이 경우는 서비스에서 진행할 수 밖에 없다.

 

또 UserSchedule, UserWorkspace와 같은 다대다 결합에서의 중간 테이블에 해당하는 객체는

비즈니스 로직을 가지지 않는다.

심지어 생성주기와 수명도 다른 엔티티에서 관리하고 있다. (스케줄이 삭제되면 같이 삭제 등등..)

 

DTO와 이런 중간 테이블 객체는 자료구조로 보고 Setter를 마음껏 사용하기로 했다.

단점 (trade-off 의 결과)

  • 엔티티 객체가 너무 무거워졌다.
  • 지금까지 존재하는 서비스 레이어를 전부 변경해야했다..

 

글이 너무 길어졌으므로 다음 글에선 이 도메인 비즈니스 로직을 사용해서

서비스 로직을 바꾸고 리팩토링한 과정과 장단점을 더 자세하게 써보려고 한다.

  • DTO의 변환은 어느 레이어에서? - 응집도와 결합도, 효율성의 트레이드 오프
  • 도메인별 생성자 전략, 빌더 패턴을 직접 만들어보자.

 

 

 

 

 

 

 

 

 

지금까지 spring mvc, jpa, security, swagger, redis와 같은 내용을 공부할때 공부한 내용을 

기존에 진행하던 스터디 일정 관리 프로젝트에 적용해보는 방식으로 공부했다.

 

그런데 이렇게 하나의 프로젝트에 여러 기능들을 넣고 적용하다보니, 패키지 구조가 복잡해지고 알아보기 힘들어졌다.

한눈에 봐도 난잡해보이는 패키지 구조다.

처음엔 controller, service, repository, domain, dto, exception만 존재했지만,

앞서 말한 여러 기능들을 추가하며 지금과 같은 구조를 띄게 되었다.

 

이러한 패키지 구조는 필요한 파일을 찾기 힘들게 하며, 여러 패키지의 코드가 얽혀있어

controller - service - repository로 이어지는 계층형 아키텍쳐의 의존관계 설정을 힘들게 해 순환참조와 같은 문제를 일으켰다.

 

그렇다면 적절한 패키지 구조는 무엇일까?

https://www.inflearn.com/questions/16046/%ED%94%84%EB%A1%9C%EC%A0%9D%ED%8A%B8-%ED%8F%B4%EB%8D%94-%EA%B5%AC%EC%A1%B0%EC%99%80-%EA%B0%95%EC%9D%98-%EC%9D%BC%EC%A0%95%EC%97%90-%EA%B4%80%ED%95%98%EC%97%AC-%EC%A7%88%EB%AC%B8%EC%9D%B4-%EC%9E%88%EC%8A%B5%EB%8B%88%EB%8B%A4

 

프로젝트 폴더 구조와 강의 일정에 관하여 질문이 있습니다. - 인프런

1. 실무에서 프로젝트 구현시 보통 폴더 구조를 어떤식으로 하시나요?프로젝트를 하는데 폴더 구조를 어떻게 해야 좋을지 궁금합니다. 강의와 같이 api 패키지를 하나 만들고 Controller 클래스 안

www.inflearn.com

 

https://cheese10yun.github.io/spring-guide-directory/

 

Spring Guide - Directory - Yun Blog | 기술 블로그

Spring Guide - Directory - Yun Blog | 기술 블로그

cheese10yun.github.io

 

패키지 구조에 대해 고민하며 구글링을 하면 가장 많이 찾을 수 있는 좋은 자료 2개를 가져와봤다.

 

계층형 vs 도메인형?

계층형 구조

project
    ├── controller
    │   ├── UserController.java
    │   ├── ScheduleController.java
    │   └── WorkspaceController.java
    ├── domain
    │   ├── User.java
    │   ├── Schedule.java
    │   └── Workspace.java
    ├── dto
    ├── exception
    ├── repository
    └── service

계층형 구조는 각 계층을 기준으로 패키지를 구성하며 다음과 같은 장단점을 가진다.

  • 처읍 보는 사람도 프로젝트의 구조를 파악하기 쉽다.
  • 프로젝트 규모가 커질수록 하나의 디렉토리에 파일들이 모이게 되고 구조를 파악하기 힘들어진다.

 

도메인형 구조

project
    ├── user
    │   ├── controller
    │   ├── domain    
    │   ├── repository
    │   └── service
    ├── schedule
    │   ├── controller
    │   ├── domain    
    │   ├── repository
    │   └── service
    └── workspace
        ├── controller
        ├── domain    
        ├── repository
        └── service

도메인형 구조는 도메인을 기준으로 패키지를 구성하며 다음과 같은 장단점을 가진다.

  • 하나의 도메인에 관련된 코드들이 응집해있어 모듈 단위 프로젝트 분리가 쉽다.
  • 프로젝트에 대한 이해도가 낮을 경우 패키지 구조를 파악하기 위한 난이도가 높아진다.

우리 프로젝트에 더 적합한 패키지 구조는 무엇일까?

나는 고민끝에 계층형 구조를 선택하기로 했다.

 

왜 계층형 구조인가요?

분명히 요즘 유행하는 프로젝트 구조는 도메인형이 맞다.

위에서 언급한 글도 그렇고, 대부분의 블로그와 깃허브 소스코드를 살펴봐도

최근엔 도메인형으로 패키지 구조를 설계하는 경우가 상당히 많다.

 

하지만 은탄환은 없다.

유행하는 구조가 있다고 해서 반드시 따라갈 필요는 없고, 내 프로젝트에 맞는 패키지 구조를 선택하는 것이 더 좋다고 생각했다.

 

이번 프로젝트의 도메인은 User, Workspace, Schedule, DevLog 4개가 전부였다.

비즈니스 로직 또한 크게 복잡하지 않아서 모든 도메인이 하나의 Controller, Service를 사용한다.

(Sprint data jpa를 사용해 Repository는 여러개를 사용했다.)

project
    ├── user
    │   ├── controller
    │   │   	└── UserController.java
    │   ├── domain
    │   │   	└── User.java
    │   ├── repository
    │   └── service
    │   │   	└── UserService.java
    ├── schedule
    │   └── ...
    ├── devLog
    │   └── ...
    └── workspace
        └── ...

도메인형을 사용하면 위의 구조처럼 대부분의 디렉토리에 디렉토리당 하나의 파일만 들어가게 된다.

나는 이게 배보다 배꼽이 큰거 아닌가? 라고 생각했다.

 

도메인이 많지 않으며, 도메인별로 연관관계는 많아 서로 참조할 일이 많은데

디렉토리만 저렇게 많이 만들면 파일 찾기만 힘들지 아닐까? 라고 생각했다.

 

그러나 이렇게 계층형 구조로 패키지 구조를 설정하기 위해선 우리 프로젝트에 몇가지 문제점이 존재했다,

 

1. 특정 계층에 속하지 않는 패키지들

기존의 패키지 중 Interceptor, filter, argumentResolver, sucurity 와 같은 패키지들은 특정 계층에 넣기 힘들다.

그렇다고 이 패키지들을 그대로 놔두면 디렉토리가 너무 많아지고 가독성에 좋지 않다.

이러한 패키지들은 특정 계층에 속하지 않고 또 여러 도메인과 계층에서 사용되므로 common 패키지를 구성하여 따로 관리했다.

 

2. 어플리케이션과 다른 정책을 가지는 admin 패키지

이번 어플리케이션은 REST API를 제공하기 위해 만들었다.

(그래서 controller 패키지의 이름도 사실 api라고 붙이는 것이 더 적절해보인다)

 

그러나 admin 페이지 정도는 백엔드에서 직접 만드는 것이 더 간단하기 때문에

타임리프를 사용해 admin 페이지를 구현하는 admin 패키지를 만들었다.

이 어드민 페이지는 5개의 컨트롤러를 가지고 있으며 기존 api의 컨트롤러와는 속성이 많이 다르다.

 

그래서 admin 패키지만 도메인 계층처럼 따로 분리하기로 결정했다.

 

결과

project
    ├── admin
    │   ├── controller
    │   └── dto
    ├── common
    │   ├── auth
    │   ├── config
    │   └── interceptor
    ├── controller
    ├── domain
    ├── dto
    ├── exception
    ├── repository
    └── service

다음과 같은 나름 깔끔한 패키지 구조를 갖게 되었다.

디렉토리 1개당 2~5개 정도의 파일을 가져 가독성도 충분하다.

 

물론 도메인형도 아니고 계층형도 아니고 이게 뭐냐 라고 불편해할 수도 있다.

사실 완벽한 방법은 없는것 같다.

프로젝트에 요구사항이 더 추가되고, 도메인이 늘어나거나 파일이 늘어나면 도메인형으로 다시 바꿀 가능성도 있다.

 

중요한건 상황에 맞는 구조를 선택하는것이 아닐까 싶다.

 

오늘의 목표 

JWT를 이용해 인증 인가를 구현하던 api에 oauth2를 이용한 소셜 로그인 기능 추가하기

 

OAUTH2 인증&인가 진행 과정

기본 동작 과정은 저번 글에서 설명했듯이 다음과 같다.

(여기서 이야기하는 Client는 실제 사용자가 아닌 Oauth Provider에 등록한 우리의 서비스임을 주의하자)

  1. Resource Owner 가 Client에 로그인 요청
  2. Client는 Authorization Server에 다시 로그인 요청(Clitne Id, Redirect URI, Response Type, Scope 전달)
  3. Authorization Server에서 Client에 로그인 페이지 제공
  4. Resource Owner 로그인 성공시 Authrorization code 와 함께 redirect uri로 리다이렉트
  5. redirect uri에서 Client는 Autorization Server에게 Authroization code를 사용해 Access Token 발급 요청
  6. Client에서 필요할때마다 Access Token으로 Resource 서버의 사용자 정보에 접근

사실 권한부여(Grant Type) 유형은 위와같은 Authorization code 유형이 아닌 다른 유형도 존재하지만,

가장 많이 사용하는 Authorization code 유형을 기준으로 진행했다.

다른 유형도 궁금하다면 OAuth2 Grant Type에 대해 검색해보자.

 

https://velog.io/@crow/OAuth-2.0-%EA%B6%8C%ED%95%9C%EB%B6%80%EC%97%AC-%EC%9C%A0%ED%98%95Grant-Type

 

OAuth 2.0 권한부여 유형(Grant Type)

123

velog.io

 

 

프론트와 백엔드가 나눠진 인증 과정

 

프론트와 백 어디가 주가 될 것인가 - Authorization Server Redirect URI를 어디로 설정하는 것이 좋은가?

 

Authorization Server의 역할을 다시 한 번 짚어볼 필요가 있다.

 

Authorization Server는 사용자 인증을 진행한 후 인증 성공시 codestate

미리 설정해놓은 Redirect URI에 쿼리 파라미터 형식으로 전달한다.

 

그리고 이 codeClient Secret 등의 정보를 사용해 Autorization Server는 Access Token을 발급한다.

이 Access Token을 헤더로 사용해야 Resource Server에서 유저 정보를 가져올 수 있다.

 

저번 글에선 이 과정을 Spring Security의 필터에게 맡겼다.

그런데 프론트와 협업하는 상황에선 이 과정을 어떻게 처리해야할까?

 

이는 Authorization Server의 Redirect URI를 어떻게 설정하느냐와 깊은 연관이 있다.

1. 프론트가 모두 담당

프론트에서 Authorization Server에 접근해 code를 발급받고 Access Token을 발급받은 다음

Access Token을 백엔드로 넘겨주거나 혹은 유저 정보까지 조회한 후 필요한 유저 정보만 백엔드로 넘겨주는 방식이다.

그러면 백엔드에서 유저 정보를 사용해 서비스용 Access Token과 Refresh Token을 생성한 후 다시 프론트로 넘겨준다.

 

전자의 경우는 상당히 위험한데, 프론트에서 Access Token과 Refresh Token을 백엔드로 전송할때 탈취의 위험이 있고,

탈취시 심각한 개인정보 유출로 이어질 수 있다.

후자의 경우 OAuth2를 단순히 유저 인증용으로만 사용할 때(로그인 이후엔 유저 정보가 필요 없을 때) 사용할 것 같다.

 

2. 프론트로 RedirctURI를 설정하고 Authorization code만 다시 백엔드로 넘겨주기

구글링하면 가장 많이 나오는 방식이다.

프론트에서 Authorization Server에 접근해 code를 발급받은 후 그 코드만 백엔드에 넘겨주는 방식이다.

Authorization code는 유효기간도 매우 짧고 탈취당해도 client_secret을 알지 못한다면 소용이 없고

client_secret과 같은 정보는 모두 백에서 관리하므로 보안에 장점이 있는 방식이다.

 

문제는 Spring Security를 적용하기가 힘들다.

 

Spring Security의 oauth2 로그인 로직은

  • OAuth2AuthorizationRequestRedirectFilter에서 OAuth2AuthorizationRequest를 생성해 쿠키에 저장하고
  • OAuth2LoginAuthenticationFilter에서 생성해뒀던  OAuth2AuthorizationReques객체를 이용해
    Access Token을 발급받는다.

OAuth2AuthorizationRequestRedirectFilter를 반드시 거쳐가야 하는데 이 필터는

/oauth2/authorization/** 와 같은 요청을 처리한다.

 

OAuth2LoginAuthenticationFilter가 처리하는 요청 url은 /login/oauth2/code/** 이다.

 

프론트에서 두 개의 url에 순서대로 요청하고 쿠키에 저장된 값을 꺼내 쓰고 하는게 너무 복잡하고 불편해보인다.

 

그렇다고 Security 안쓰고 직접 구현하는건 쉽냐? 그거도 아니다.

백엔드 서버에서 직접 Auhtorization Server랑 Resource Server에 접근해야하며,

결과 값 파싱까지 귀찮은게 한두가지가 아니다.

 

3. 백이 모두 담당

프론트에선 백의 인증 url만 호출하고 백엔드에서 인증 로직을 모두 전담하는 방식이다.

백엔드에서 모든 인증을 끝낸 후 프론트와 미리 약속한 redirect url에 (Authorization Server의 redirect URI와 다르다)

서비스용 access token, refresh token만 전달하면 된다.

 

이렇게하면 Authorization Server의 Redirect URI는 localhost:8080/login/oauth2/code/**

같은 백엔드 api 주소로 설정해 Spring Security를 사용하기 쉬워지며

Client Secret과 같은 보안 정보를 모두 백엔드에서 관리해 보안성도 높아진다.

 

나는 3번 방식을 사용하기로 결정했다.

 

우리가 선택한 방식의 인증 인가 진행 과정

  1. 프론트에서 백엔드에 로그인 페이지를 요청하는 것으로 시작
  2. 백엔드의 /oauth2/authorization/** 로 바로 리다이렉트 -> 로그인 페이지 제공
  3. 로그인 성공시 /login/oauth2/code/** 로 리다이렉트 -> 시큐리티 필터 동작
  4. 지난번에 만든 유저 서비스가 Authentication 객체 만들어 Security Context에 넣어둠
  5. SuccessHandler가 Authentication 객체에서 principal 꺼내서 JWT 제작
  6. 마지막에 프론트 리다이렉트 url로 토큰 반환

SecurityConfig 수정

@Configuration
@RequiredArgsConstructor
@EnableWebSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter {
    
    @Bean
    public BCryptPasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();
    }
    
    private final CustomOAuth2UserService customOAuth2UserService;
    private final OAuth2AuthenticationSuccessHandler oAuth2AuthenticationSuccessHandler;
    private final JwtProvider jwtProvider;
    
    @Override
    protected void configure(HttpSecurity http) throws Exception {
        
        http.csrf().disable();
        
        http
            	.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
            .and()
            	.formLogin().disable()
            	.httpBasic().disable()
            	.addFilter(new JwtAuthorizationFilter(authenticationManager(), jwtProvider))
            	.authorizeRequests()
            		.antMatchers("/v1/login").permitAll()
            		.antMatchers("/v1/refresh").permitAll()
            		.antMatchers("/v3/api-docs/**", "/swagger-ui/**", "/swagger-resources/**").permitAll()
            		.antMatchers("/admin/**", "/css/**", "/*.ico", "/error").permitAll()
            		.anyRequest().authenticated()
            .and()
            	.oauth2Login()
            	    .userInfoEndpoint()
                	.userService(customOAuth2UserService)
            		.and()
            		.successHandler(oAuth2AuthenticationSuccessHandler);
    }
}

 

Session을 이용해 유저 정보를 저장하지 않기 때문에 sessionCreationPolicy를 STATELESS로 설정했다.

 

기존의 JWT 인증,인가 방식을 그대로 사용하며 OAuth2 소셜 로그인을 추가해야하므로

기존 JWT 설정 (JwtAuthorizationFilter, JwtProvider) 를 그대로 사용하기로 했다.

 

그리고 지난 글에서 사용했던 customOAuth2UserService, UserInfo, OAuth2Attributes를 그대로 사용하기로 했다.

 

기존 로직은 OAuth2 로그인 성공 후 SecurityContext에 Authentication 객체를 저장하기만 하면

SecurityContext가 세션에 저장되어 인증 인가를 처리할 수 있었다.

 

그러나 이젠 Session에 저장하는 것이 아닌 JWT를 만들어 발급해야 하므로 로그인 성공 후 토큰을 만들어야 하는데

이를 CustomSuccessHandler 를 구현해 처리할 예정이다.

 

CustomSuccessHandler 구현

@RequiredArgsConstructor
@Component
public class OAuth2AuthenticationSuccessHandler extends SimpleUrlAuthenticationSuccessHandler {
    private final JwtProvider jwtProvider;
    
    private final String REDIRECT_URL = "http://localhost:3000/login/redirect";
    
    @Override
    public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException, ServletException {

        UserInfo userInfo = (UserInfo) authentication.getPrincipal();
        User loginUser =  userInfo.getUser();
        
        String accessToken = jwtProvider.createAccessToken(loginUser);
        
        String refreshToken = jwtProvider.createRefreshToken(loginUser);
        
        getRedirectStrategy().sendRedirect(request, response, getRedirectUrl(accessToken, refreshToken));
    }
    
    private String getRedirectUrl(String accessToken, String refreshToken){
        return UriComponentsBuilder.fromUriString(REDIRECT_URL)
                .queryParam("access_token", accessToken)
                .queryParam("refresh_token", refreshToken)
                .build().toUriString();
    }
}

 

SimpleUrlAuthenticationSuccessHandler를 상속받아 구현했다.

 

인증 성공 후 생성된 Authentication에서 UserInfo를 다시 꺼낸 후 access token과 refresh token을 생성했다,

그리고 생성된 refresh token과 access token을 프론트와 미리 약속한 redirect url에 전송하는 로직을 처리한다.

 

인증, 인가는 공부를 할 수록 의문점만 생기는 부분인 것 같다.

프론트에서 백으로 access token을 전송하는건 위험하다는데 백에서 프론트로 전송하는 건 괜찮은건가?

프론트에서 보관하고 있는 access token을 어떻게 탈취한다는 걸까?

redirect url을 백엔드 api로 설정하면 유저 브라우저로 백엔드 api를 직접 호출하는 것인데 문제는 없는가?

 

협업 프로젝트를 진행해보며 더 공부해봐야할 내용인 것 같다.

인증 인가를 구현하는 방식으로 session과 jwt 방식을 알아봤다.

이번엔 Spring Security에서 OAuth2를 이용해 인증, 인가를 구현하는 방법에 대해 공부해봤다.

OAuth2란?

구글, 페이스북, 카카오와 같은 다양한 플랫폼의 사용자 데이터에 대해 접근하기 위해

제 3자가 사용자 데이터에 대한 접근 권한을 위임받는 표준 프로토콜이다.

 

https://hudi.blog/oauth-2.0/

 

OAuth 2.0 개념과 동작원리

2022년 07월 13일에 작성한 글을 보충하여 새로 포스팅한 글이다. OAuth 등장 배경 우리의 서비스가 사용자를 대신하여 구글의 캘린더에 일정을 추가하거나, 페이스북, 트위터에 글을 남기는 기능을

hudi.blog

 

OAuth2를 이용해 인증, 인가를 구현하려면 어떻게 해야 하나요?

기존의 session, jwt 방식이 함께 사용된다고 생각하면 된다.

다만 session에 넣는 유저 정보를 OAuth2를 통해 가져온다.

 

OAuth2를 사용해 인증, 인가를 구현하면 얻을 수 있는 장점?

  1. 개발자는 로그인 폼, 회원가입, 아이디, 비밀번호 찾기 등과 같은 귀찮은 과정을 구현하지 않아도 된다.
  2. 사용자의 중요 정보를 DB에 저장하지 않아도 됨 -> 개인정보 유출 위험성을 낮출 수 있다.
  3. 사용자 또한 어플리케이션마다 계정을 만들고 관리할 필요가 없어 편리한 로그인이 가능해진다.

기본 용어

Resource Owner : 회원 정보의 소유자, 즉 실제 사용자

Client : 회원 정보를 사용하는 사용자 , 즉 우리가 만든 어플리케이션

Authorization Server : 인증용 서버

Resource Server : 회원 정보 제공 서버

 

기본 동작 방식

  1. Resource Owner 가 Client에 로그인 요청
  2. Client는 Authorization Server에 다시 로그인 요청(Clitne Id, Redirect URI, Response Type, Scope 전달)
  3. Authorization Server에서 Client에 로그인 페이지 제공
  4. Resource Owner 로그인 성공시 Authrorization code 와 함께 redirect uri로 리다이렉트
  5. redirect uri에서 Client는 Autorization Server에게 Authroization code를 사용해 Access Token 발급 요청
  6. Client에서 필요할때마다 Access Token으로 Resource 서버의 사용자 정보에 접근

이때 Spring Security를 사용하면

  • Autorization Server에 접근하는 uri 제공 (google, github, facebook)
  • Resource Server에 접근하는 uri,  Autorization code 값 저장 방법 등 제공
  • Access Token 관리 및 가져온 유저 정보 저장

등의 기능을 편리하게 사용할 수 있다.

 

이번 글의 목표

기존의 타임리프를 사용한 로그인 페이지에서 oauth2를 이용한 로그인 방식을 함께 구현해보자.

 

기존 프로젝트에 타임리프와 spring security의 formLogin 기능을 이용해서 어드민 페이지를 만들었다.

여기서 기존 로그인 방식을 유지하면서 oauth2를 이용한 소셜 로그인 기능을 추가해보자.

 

OAuth2를 사용하기 위한 설정 변경

1. 어플리케이션 등록

우리는 네이버와 구글을 이용해서 구현할 예정이다.

네이버와 구글로 소셜 로그인 기능을 사용하기 위해선 우리 서비스를 클라이언트로 등록해야 한다.

아래 링크를 참고해서 등록했다.

https://velog.io/@leeeeeyeon/%EC%8A%A4%ED%94%84%EB%A7%81-%EC%8B%9C%ED%81%90%EB%A6%AC%ED%8B%B0%EC%99%80-OAuth-2.0%EC%9C%BC%EB%A1%9C-%EB%A1%9C%EA%B7%B8%EC%9D%B8-%EA%B8%B0%EB%8A%A5-%EA%B5%AC%ED%98%84%ED%95%98%EA%B8%B0

 

스프링 시큐리티와 OAuth 2.0으로 로그인 기능 구현하기

구글 로그인, 로그아웃, 회원가입, 권한관리 기능을 구현해보자

velog.io

 

2. application.yml 추가

spring:
  security:
    oauth2:
      client:
        registration:
          google:
            client-id: ...
            client-secret: ...
            scope: profile, email
            redirect-uri: https://localhost:8080/login/oauth2/code/google
          naver:
            client-id: ...
            client-secret: ...
            scope: name, email
            client-name: Naver
            authorization-grant-type: authorization_code
            redirect-uri: https://localhost:8080/login/oauth2/code/naver
        provider:
          naver:
            authorization-uri: https://nid.naver.com/oauth2.0/authorize
            token-uri: https://nid.naver.com/oauth2.0/token
            user-info-uri: https://openapi.naver.com/v1/nid/me
            user-name-attribute: response

 

google은 스프링 시큐리티에서 기본으로 Provider를 제공해주므로 위의 4가지 정보만 설정하면 된다.

그러나 네이버는 시큐리티에서 제공하지 않으므로 Provider 설정도 직접 해줘야한다.

 

여기서 중요한 설정은 redirect-uri로 우리는 반드시 .../login/oauth2/code/google 와 같은 형식으로 uri를 등록해야한다.

그리고 여기 등록한 uri는 어플리케이션 등록시 설정하는 redirect-uri와 동일해야한다.

그 이유는 아래에 후술한다.

 

3. SecurityConfig 파일 변경

@EnableWebSecurity
@Configuration
@RequiredArgsConstructor
public class OAuth2SecurityConfig {

    @Bean
    public BCryptPasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();
    }
    
    private final CustomFailureHandler customFailureHandler;
    private final CustomOAuth2UserService customOAuth2UserService;

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http
                .authorizeRequests((authorizeRequests) -> authorizeRequests
                        .requestMatchers(
                                new AntPathRequestMatcher("/admin/login")
                        ).permitAll()
                        .anyRequest().authenticated()
                    )
                .formLogin()
                    .loginPage("/admin/login")
                    .loginProcessingUrl("/admin/login")
                    .usernameParameter("accountId")
                    .defaultSuccessUrl("/admin")
                .and()
                .logout((logoutConfig) -> logoutConfig
                    .logoutUrl("/admin/logout")
                    .logoutSuccessUrl("/admin")
                ) 
                //OAuth2를 위한 설정 추가
                .oauth2Login((oauth2) -> oauth2
                    .loginPage("/admin/login") 
                    .defaultSuccessUrl("/admin")
                    .userInfoEndpoint(userInfoEndpoint -> userInfoEndpoint
                        .userService(customOAuth2UserService))); //사용자 user service 추가

        return http.build();
    }    
}

 

기존의 Spring Security 설정 파일에 oauth2Login 설정을 추가하자.

formLogin 설정과 얼추 비슷해보인다.

 

복습 - 스프링 시큐리티의 기본 동작 방식

 

기존의 formLogin을 이용한 인증 구조는 위와 같았다.

 

설정을 통해 미리 만들어놓은 Authentication Filter에서 설정해놓은 URL로 들어오는 요청을 확인하면

AuthenticationManger가 인증을 처리할 Provider를 찾고

formLogin은 UserDetailsService를 통해 유저 정보를 가져와 Authentication 객체를 만들어

SecurityContext에 저장해놓고 사용했다.

 

OAuth2를 이용한 인증/인가시 몇가지 구현 객체만 바뀔 뿐 기본적인 구조는 위의 방식과 동일하다.

@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
    http //OAuth2 설정 추가
       .oauth2Login((oauth2) -> oauth2
           .loginPage("/admin/login") 
           .defaultSuccessUrl("/admin")
           .userInfoEndpoint(userInfoEndpoint -> userInfoEndpoint
           .userService(customOAuth2UserService)));
}

 

위와 같이 oauth2Login 로그인 설정을 추가하면 아래와 같은 필터 체인이 추가된다.

  • OAuth2AuthorizationRequestRedirectFilter
  • OAuth2LoginAuthenticationFilter
  • (사실 몇개 더 있지만 이 두 가지만 짚고 가자)

 

OAuth2AuthorizationRequestRedirectFilter

GET http://localhost:8080/oauth2/authorization/google

다음과 같은 양식의 요청이 들어오면 해당 요청을 처리하는 필터다.

 

요청 url과 파라미터를 검증해 조건에 맞으면 OAuth2AuthorizationRequest 객체를 생성하여 리턴한다.

 OAuth2AuthorizationRequest 객체의 authorizationRequestUri 주소로 사용자를 리다이렉트 한다.

이 주소에는 각 제공자에 맞는 OAuth2 로그인 페이지 URL이 담겨있다.

 

OAuth2LoginAuthenticationFilter

/login/oauth2/code/* 와 같은 양식의 요청이 들어오면 해당 요청을 처리하는 필터다.

 

그런데 이 양식 어디서 많이 봤다. 바로 redirect Uri 에서 설정한 uri 양식이다.

사용자가 로그인에 성공하면 redirect URI로 이동한다.

이때 우리는 redirect URI를 OAuth2LoginAuthenticationFilter가 처리하는 uri로 설정해 둔 것이다.

 

OAuth2LoginAuthenticationFilter는 Authentication Server에서 AccessToken을 발급받고,

AccessToken을 이용해 사용자 정보를 받아와 인증 객체(Authentication)를 생성해 리턴하는 중요 로직을 담당한다.

 

이때 실제 로직은 AuthenticationProvider에게 위임한다.

 

OAuth2LoginAuthenticationProvider

OAuth2LoginAuthenticationProvider 객체는 인가 코드로 OAuth2 제공자 인증 서버에서 액세스 토큰을 발급 받고,

액세스 토큰으로 사용자 정보를 받아 인증 객체를 생성하고 리턴하는 로직을 처리합니다.

이 때 액세스 토큰 발급과 사용자 정보를 받아오는 부분은 각각 

OAuth2AuthorizationCodeAuthenticationProvider 객체와 UserService 객체에게 위임합니다.

 

우리가 구현해야하는 부분

1. UserService 구현

@Service
@RequiredArgsConstructor
public class CustomOAuth2UserService implements OAuth2UserService<OAuth2UserRequest, OAuth2User> {
    
    private final UserRepository userRepository;
    
    @Override
    public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException{
        //attributes 얻기 위한 코드
        OAuth2UserService<OAuth2UserRequest, OAuth2User> delegate = new DefaultOAuth2UserService();
        OAuth2User oAuth2User = delegate.loadUser(userRequest);
        
        String registrationId = userRequest.getClientRegistration().getRegistrationId();
        Map<String, Object> attributes = OAuthAttributes.of(registrationId, oAuth2User);

        User user = saveOrUpdate(attributes);
        return UserInfo.ofOAuth2(user, attributes);
    }
    
    //유저 생성 서비스 로직
    private User saveOrUpdate(Map<String, Object> attributes){

        Optional<User> findUser = userRepository.findByEmail((String) attributes.get("email"));
        
        if(findUser.isPresent()){
            return findUser.get();
        }
        else{
            User user = User.fromOAuth2Attributes(attributes);
            userRepository.save(user);
            
            return user;
        }
    }
}

사용자가 로그인에 성공한 후 Access Token을 받는데 까지 성공하면 해당 Access Token을 이용해 유저 정보를 얻어올 수 있다. 이 과정은 DafultOAuth2UserService의 loadUser() 메서드가 구현하고 있으므로 사용하면 된다.

 

이렇게 가져온 유저 정보를 OAuth2User 객체 인스턴스로 반환하면

AbstractAuthenticationProcessingFilter 에서 Authentication 객체를 만들어 SecurityContext에 저장한다.

 

그런데 코드를 잘보면 주목할 점이 몇가지 있다.

DafultOAuth2UserService에서 반환한 OAuth2User 인스턴스를 그냥 반환하면 되는데

 

  1. Map<String, Object> 형태의 attributes를 뽑아내서
  2. 일치하는 User를 찾거나 없으면 새로 생성해내고
  3. 유저 인스턴스를 다시 UserInfo 객체로 변환해 반환한다.

왜 이렇게 쓸데없는 고생을 할까?

그 이유는 우리가 기존 formLogin 방식을 같이 사용하기 때문이다.

 

기존 formLogin 방식의 UserService를 보자.

@Service
@RequiredArgsConstructor
public class UserServiceImpl implements UserService, UserDetailsService{
    
    private final UserRepository userRepository;
    ...
    
    @Transactional(readOnly = true)
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException{
        UserDetails findUser = userRepository.findByAccountId(username)
            .orElseThrow(() -> new UsernameNotFoundException("존재하지 않는 회원입니다"));
        
        return findUser;
    }
    
}

UserDetailsService를 상속받아 loadUserByUsername() 메서드를 오버라이딩했다.

유저 식별자(username)이 들어오면 DB에서 해당하는 유저 정보를 꺼내와 UserDetails 형태로 반환한다.

그 후 UserDetails 인스턴스를 이용해 Authentication 객체를 만들어 SecurityContext에 저장한다.

 

여기서 생기는 문제는 우리는 유저가 OAuth2 로그인을 할지, formLogin을 사용할지 알 수 없다!

 

그렇다면 유저 정보를 확인할 필요가 있을 때 SecurityContext에 저장된 Authentication 객체의 유저 정보가

OAuth2User 일지, UserDetails일지 꺼내서 확인하기 전까진 알 수 없다는 것이다!

 

따라서 Spring Security의 가장 핵심 기능인 Security Context에 유저 정보를 저장하고

필요한 상황이 생기면 전역적으로 사용하는 기능을 사용하기 힘들어졌다.

 

로그인한 유저 정보를 확인해야하는 곳마다 타입체크와 형변환을 하는 코드를 사용하는 것은

엄청나게 번거롭고 비효율적인 짓이기 때문이다.

 

이를 해결하기 위해 UserInfo 클래스를 만들었다.

 

2. UserInfo 클래스 구현

@Getter
public class UserInfo implements UserDetails, OAuth2User{

    private User user;
    private Map<String, Object> attributes;

    private UserInfo(User user, Map<String, Object> attributes){
        this.user = user;
        this.attributes = attributes;
    }
    
    public static UserInfo ofOAuth2(User user, Map<String, Object> attributes){
        return new UserInfo(user, attributes);
    }
    
    public static UserInfo from(User user){
        return new UserInfo(user, null);
    }
    
    //<== UserDetails ==>//
    public Collection<? extends GrantedAuthority> getAuthorities(){
        return Collections.singleton(new SimpleGrantedAuthority(user.getRoleKey()));
    }
	...
    //<== Oauth2User ==>
    public String getName(){
        return user.getName();
    }
	...
}

UserInfo 클래스는 UserDails와 OAuth2User를 모두 구현하는 클래스이다.

이는 UserDails와 OAuth2User 모두 인터페이스이기 때문에 가능하다.

 

이로서 우리는 OAuth2Login에서든 FormLogin에서든 UserInfo 클래스를 반환하도록 구현하면 된다.

유저 정보가 필요할때마다 번거롭게 타입체크와 형변환을 할 필요 없이 주요 정보들을 가져올 수 있다.

 

그리고 OAuth2 방식과 formLogin 방식이 UserInfo를 생성하기 위해 필요한 매개변수가 다르기 때문에

정적 팩토리 메서드를 사용해봤다.

 

3. OAuthAttributes 구현

public class OAuthAttributes{
    
    private OAuthAttributes() {
        throw new UnsupportedOperationException();
    }
    
    public static Map<String, Object> of(String registrationId, OAuth2User oauth2User){
        
        switch(registrationId){
            case "google":
                return ofGoogle(oauth2User);
                
            case "naver":
                return ofNaver(oauth2User);
            
            default:
                throw new IllegalStateException("unvalid oauth2 login");
        }
    }
    
    private static Map<String, Object> ofGoogle(OAuth2User oauth2User){
        return oauth2User.getAttributes();
    }

    private static Map<String, Object> ofNaver(OAuth2User oauth2User){
        
        return (Map<String, Object>) oauth2User.getAttributes().get("response");
    }
}

OAuth2의 제공자마다 유저 정보 응답이 조금씩 다르다.

따라서 제공자마다 유저 정보로 넘어온 응답값을 추출해내는 과정이 필요한데 이를 하나의 클래스로 분리해봤다.

 

사실 UserService의 메서드로 넣어도 되는 메서드들이긴 하다.

근데 가만히 보니까 서비스 로직들 사이에 들어가있는게 불편해서 한 번 분리해봤다.

 

처음 판단은 이랬다.

  • OAuth2의 제공자가 지금은 구글과 네이버 뿐이지만 언제 더 늘어날지 모른다.(변경이 자주 일어날 것으로 예상)
  • 그렇다면 제공자가 늘어날때마다 UserService의 로직을 수정해야하만 한다.
  • 그러나 UserService에서 OAuth2 제공자가 추가될때 변경되어야 하는 로직은 응답값 추출과 관련된 메서드들 뿐이다.
  • 그렇다면 응답값 추출 메서드를 별도의 클래스로 분리해보면 어떨까?

그런데 분리를 하고 보니까 스태틱 메서드만 포함하고 있는 유틸리티 클래스가 생겨버렸다.

유틸리티 클래스에 대해선 논쟁이 많은 편이다. 특히 객체지향 프로그래밍과 맞지 않는다는 이야기를 많이 봤다.

 

유틸 클래스의 문제점으로 꼽히는 점은 사용하는 클래스와의 강한 결합이다.

유틸 클래스는 스태틱 메서드로만 만들어져 인스턴스를 생성하지 않고 사용한다.

그래서 유틸 클래스를 사용하는 클래스와 강하게 결합되는데

이는 유틸 클래스가 변경될 시 사용하는 모든 클래스의 변경을 유발한다.

 

그러나 UserService의 메서드를 분리해 만든 OAuth2Attributes 클래스는 UserService가 아닌 곳에서 사용될 일이 없다.

그래서 그냥 쓰기로 했다.

 

4. LoginPage 변경

<!DOCTYPE HTML>
...
<body>
    <div class="container">
        <form role="form" th:action="@{/admin/login}" method="post" th:object="${signInRequest}">
            ...
        </form>
        <div>
            <a href="/oauth2/authorization/google">Google Login</a> <br/>
            <a href="/oauth2/authorization/naver">Naver Login</a> <br/>    
        </div>
    </div> <!-- /container -->
</body>
</html>

로그인 페이지에 구글 로그인 네이버 로그인용 태그를 추가해줘야한다.

이때 링크 클릭시 url은 위의 OAuth2AuthorizationRequestRedirectFilter가 처리할 수 있도록

/oauth2/authorization/* 양식으로 해줘야한다.

5. AdminController 구현

    @GetMapping("/admin") //홈 화면
    public String newHome(@AuthenticationPrincipal UserInfo findUser, Model model){
        
        if (findUser == null){
            return "admin/home";
        }
        
        model.addAttribute("userName", findUser.getName());    
        return "admin/menu";
    }

위의 방식대로 구현하면 컨트롤러 단에선 UserInfo 만 받아온 후 형변환 없이 그대로 사용할 수 있게 된다.

 

 

기존 formLogin 방식에 oauth2Login을 사용해 소셜 로그인 기능을 추가해봤다.

기존 formLogin과 함께 사용하기 위해 Session 방식으로 구현했다.

다음으론 JWT를 이용한 OAuth2 로그인도 공부해봐야겠다.

인증 로직

  1. 아이디 & 비밀번호 담아서 서버에 요청
  2. 서버에서 검증
    • 올바르다면 AccessToken, Refresh Token 생성 후 유저에게 반환
    • Refresh Token은 Redis에 따로 저장
    • 올바르지 않다면 에러 메세지 반환

기존 Spring Security의 인증로직이다.

 

기존 form Login 방식의 인증용 필터인 UsernameAuthenticationFilter는 

유저 아이디와 비밀번호를 요청으로 받아서 확인한 후

Authentication 객체를 만들어서 securityContext에 넣어 사용한다.

그 후 응답시에 HttpSession에 Authentication 객체를 넣은 후 세션 ID를 쿠키로 설정해둔다.

 

이 인증로직을 재활용하려면 인증용 필터를 새롭게 만들어 줘야 하는데

우리가 해야할 일은 HttpBody로 유저 아이디, 비밀번호 받아서

Access Token, Refresh Token을 만든 후 HttpBody에 넣어 반환하는 로직이다.

 

Form 방식이 아니므로 필터를 상속받는것 보다 RestController를 새로 만드는 것이 더 간단해보인다.

LoginController를 만들자.

@Slf4j
@RestController
@RequiredArgsConstructor
public class LoginController{
    
    private final UserServiceImpl userService;
    private final JwtProvider jwtProvider;
    
    //인증 로직
    @PostMapping("/v1/login")
    public ResponseEntity<LoginResponse> loginUser(@Valid @RequestBody LoginRequest request){ 
        User loginUser = userService.signIn(request.getAccountId(), request.getPassword());

        String accessToken = jwtProvider.createAccessToken(loginUser);
        
        String refreshToken = jwtProvider.createRefreshToken(loginUser);
        
        LoginResponse response = new LoginResponse(accessToken, refreshToken);

        return ResponseEntity.ok(response);
    }
	
    //토큰 재발급 로직
    @PostMapping("/v1/refresh")
    public ResponseEntity<String> refreshToken(@RequestHeader("Refresh-Token") String refreshToken){
        
        if(jwtProvider.validateRefreshToken(refreshToken)){
            
            String accountId = getUserAccounId(refreshToken);
            
            User user = userService.findByAccountId(accountId);
            
            String accessToken = jwtProvider.createAccessToken(user);
            
            return ResponseEntity.ok(accessToken);
        }
        return ResponseEntity.ok("Unvalid Refresh-Token");
    }
    
    private String getUserAccounId(String refreshToken){
        refreshToken = refreshToken.replace("Bearer ", "");
        
        return jwtProvider.parseClaims(refreshToken)
                .get("accountId")
                .toString();
    }
}

 

그리고 JWT 관련된 로직을 수행할 JwtProvider를 만들자.

@Slf4j
@RequiredArgsConstructor
@Component
public class JwtProvider {
    //만료시간 : 30분
    private final Long ACCESS_EXP_TIME = 1000L * 60 * 30;
    
    //만료 시간 : 하루
    private final Long REFRESH_EXP_TIME = 1000L * 60 * 60 * 24;
    
    private final RedisService redisService;
    
    @Value("${jwt.secret}")
    private String salt;
    
    private Key secretKey;
    
    @PostConstruct
    protected void init(){
        secretKey = Keys.hmacShaKeyFor(salt.getBytes(StandardCharsets.UTF_8));
    }
    
    //Access Token 생성
    public String createAccessToken(User user){
        
        String accountId = user.getAccountId();

        String authorities = user.getAuthorities().stream()
            .map(GrantedAuthority::getAuthority)
            .collect(Collectors.joining(","));
        
        Date expiration = new Date(System.currentTimeMillis() + ACCESS_EXP_TIME);

        return Jwts.builder()
                .claim("accountId", accountId)
                .claim("authorities", authorities)
                .setExpiration(expiration)
                .signWith(SignatureAlgorithm.HS256, secretKey)
                .compact();
    }
    
    //Refresh Token 생성
    public String createRefreshToken(User user){
        String accountId = user.getAccountId();
        
        Date expiration = new Date(System.currentTimeMillis() + REFRESH_EXP_TIME);
        
        String refreshToken = Jwts.builder()
                                .claim("accountId", accountId)
                                .setExpiration(expiration)
                                .signWith(SignatureAlgorithm.HS256, secretKey)
                                .compact();
        
        //redis 유효기간 설정
        redisService.setValues(accountId, refreshToken);
        redisService.setExpiration(accountId, REFRESH_EXP_TIME);
        
        return refreshToken;
    }
    
    public Claims parseClaims(String tokenString){
        return Jwts.parserBuilder()
            .setSigningKey(secretKey)
            .build()
            .parseClaimsJws(tokenString)
            .getBody();
    }
   
}

 

 

RefreshToken은 Redis에 저장할 것이므로, RedisService를 만들어서 연결하자.

@Service
@RequiredArgsConstructor
public class RedisService{
    
    private final RedisTemplate redisTemplate;
    
    public String getValues(String key){
        ValueOperations<String, String> values = redisTemplate.opsForValue();
        return values.get(key);
    }
    
    public void setValues(String key, String value){
        ValueOperations<String, String> values = redisTemplate.opsForValue();
        values.set(key,value);
    }
    
    public void setExpiration(String key, Long time){
        int second = time.intValue();
        
        redisTemplate.expire(key, second, TimeUnit.SECONDS);
    }
    
    public void deleteByKey(String key){
        redisTemplate.delete(key);
    }
}

 

RefreshToken을 Redis에 저장하는 이유와 저장하는 방법?

Access Token의 탈취를 대비하기 위해서 Refresh 토큰을 만들어 사용하지만

Refresh Token 역시 탈취당할 가능성이 존재한다.

 

Refresh Token은 유효기간이 상대적으로 긴 편이므로

탈취당했을 경우, 악성 사용자가 긴 시간동안 Access Token을 재발급하며 사용할 수 있다.

 

따라서 Refresh Token 발급시에 토큰에 대한 정보를 저장소에 저장해 놓은 후

Refresh 토큰이 탈취된 사실을 알거나, 이상한 접근이 감지될 경우 저장소에서 삭제하는 등 대처가 가능하도록 한다.

 

이번 프로젝트에선 접근 속도가 빠른 redis를 사용하기로 했다.

 

유저 아이디(식별자)를 키로, 토큰을 값으로 설정하여 저장하기로 했는데

이렇게 한다면 만약 secret key가 유출되어 리프레쉬 토큰이 위조될 경우

  1. 토큰 검증
  2. 이상 없을 경우 토큰에서 유저 아이디 확인
  3. redis에서 유저 아이디로 기존 토큰과 비교 -> 위조 여부 한번 확인 가능

다음과 같은 로직으로 토큰 위조 여부를 확인하기 쉬워진다.

 

또한 보안이 중요한 서비스라면 사용자의 최근 요청 IP등을 같이 저장해

다른 IP에서 요청이 들어올 경우 메일을 보내거나,

Refresh Token을 삭제하는 등의 대처가 가능하도록 할 수 있겠다.

 

인가 로직

  1. 자원 접근 요청시 Autorization 헤더에 Access Token 담아서 요청
  2. 서버에서 Access Token 검증
    • 유효한 토큰이면 정상 응답 반환
    • 올바르지 않다면 에러 메세지 반환
  3. Access Token 만료시, Refresh Token을 헤더에 담아 서버에 재발급 요청
  4. Redis에 저장된 Refresh 토큰과 비교 후, 올바른 Refresh Token일시 Access Token 재발급

 

JWT 방식은 인증이 필요한 매 요청마다 Autorization 헤더에서 AccessToken을 찾아 확인해야한다.

이 과정은 Filter를 새로 만들어서 적용한다.

@Slf4j
public class JwtAuthorizationFilter extends BasicAuthenticationFilter{

    private final JwtProvider jwtProvider;
    
    @Autowired
    public JwtAuthorizationFilter(AuthenticationManager authenticationManager,
                                 JwtProvider jwtProvider){
        super(authenticationManager);
        this.jwtProvider = jwtProvider;
    }
    
    @Override
    protected void doFilterInternal(HttpServletRequest request,
                                    HttpServletResponse response,
                                    FilterChain chain) throws IOException, ServletException {
        
        String accessToken = request.getHeader("Authorization");
        
        if (accessToken == null){
            chain.doFilter(request, response);
            return;
        }
        
        if (jwtProvider.validateAccessToken(accessToken)){
            Authentication authentication = jwtProvider.getAuthentication(accessToken);
            SecurityContextHolder.getContext().setAuthentication(authentication);    
        }
        
        chain.doFilter(request, response);
    }
}

JwtProvider에 인증 관련 로직을 추가한다.

@Slf4j
@RequiredArgsConstructor
@Component
public class JwtProvider {
    ...
    
    // Access Token 검증
    public boolean validateAccessToken(String accessToken){
        
        if (!accessToken.startsWith("Bearer")){
            log.info("Token not start with Bearer");
            return false;
        }
        
        accessToken = accessToken.replace("Bearer ", "");
        
        try{
            Claims claims = parseClaims(accessToken);
            
            if (claims.get("accountId") != null && claims.get("authorities") != null){
                return true;    
            }
        } catch (io.jsonwebtoken.security.SecurityException | MalformedJwtException e) {
            log.info("Invalid JWT Token", e);
        } catch (ExpiredJwtException e) {
            log.info("Expired JWT Token", e);
        } catch (UnsupportedJwtException e) {
            log.info("Unsupported JWT Token", e);
        } catch (IllegalArgumentException e) {
            log.info("JWT claims string is empty.", e);
        }
        return false;
    }
    
    //Access Token -> Authentication 
    public Authentication getAuthentication(String accessToken){
        
        accessToken = accessToken.replace("Bearer ", "");
        
        Claims claims = parseClaims(accessToken);
        
        Collection<? extends GrantedAuthority> authorities = getAuthorityList(claims);
        
        UserDetails principal = new User(claims.get("accountId").toString(), "", "");
        
        return new UsernamePasswordAuthenticationToken(principal, "", authorities);
    }
    
    // Refresh Token 검증
    public boolean validateRefreshToken(String refreshToken){
        
        if (!refreshToken.startsWith("Bearer")){
            log.info("Token not start with Bearer");
            return false;
        }
        
        refreshToken = refreshToken.replace("Bearer ", "");
        
        try{
            Claims claims = parseClaims(refreshToken);
            
            String accountId = claims.get("accountId").toString();
            
            if (accountId != null && redisService.getValues(accountId) != null){
                return true;    
            }
        } catch (io.jsonwebtoken.security.SecurityException | MalformedJwtException e) {
            log.info("Invalid JWT Token", e);
        } catch (ExpiredJwtException e) {
            log.info("Expired JWT Token", e);
        } catch (UnsupportedJwtException e) {
            log.info("Unsupported JWT Token", e);
        } catch (IllegalArgumentException e) {
            log.info("JWT claims string is empty.", e);
        }
        return false;
    }
    
    public Claims parseClaims(String tokenString){
        return Jwts.parserBuilder()
            .setSigningKey(secretKey)
            .build()
            .parseClaimsJws(tokenString)
            .getBody();
    }
    
    private List<GrantedAuthority> getAuthorityList(Claims claims){
        return Arrays.stream(claims.get("authorities").toString().split(","))
                    .map(SimpleGrantedAuthority::new)
                    .collect(Collectors.toList());
    }
}

 

필터 제작 후 Security Config에 FIlter를 적용할 범위를 설정한다.

@Slf4j
@Configuration
@EnableWebSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter {
    
    @Bean
    public BCryptPasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();
    }
    
    @Autowired
    JwtProvider jwtProvider;

    //JWT
    @Override
    protected void configure(HttpSecurity http) throws Exception {
        
        http.csrf().disable();
        
        http
            .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
            .and()
            .formLogin().disable()
            .httpBasic().disable()
            .addFilter(new JwtAuthorizationFilter(authenticationManager(), jwtProvider))
            .authorizeRequests()
            .antMatchers("/v1/login").permitAll()
            .antMatchers("/v1/refresh").permitAll()
            .antMatchers("/v3/api-docs/**", "/swagger-ui/**", "/swagger-resources/**").permitAll()
            .anyRequest().authenticated();
    }

}

 

 

여기서 Refresh 요청마다 Refresh Token도 재발급하는

Refresh Token Rotation 방식을 통해 보안성을 더 높일 수도 있다.

 

https://junior-datalist.tistory.com/352

 

Refresh Token Rotation 과 Redis로 토큰 탈취 시나리오 대응

I. 서론 JWT와 Session 비교 및 JWT의 장점 소개 II. 본론 Access Token과 Refresh Token의 도입 이유 Refresh Token 은 어떻게 Access Token의 재발급을 도와주는 걸까? Refresh Token Rotation Redis 저장 방식 변경 III. 결론

junior-datalist.tistory.com

 

다음 글에선 악성 사용자들의 공격이 어떤식으로 이루어지는지,

Spring Security에선 어떤 식으로 방어하는 기능을 제공하는지 더 자세히 알아본 후 정리해봐야겠다.

JWT란?

JWT(Json Web Token) 은 웹에서 사용되는 JSON 형식의 토큰에 대한 표준 규격

사용자의 인증, 인가 정보를 주고 받기 위해 사용한다.

 

주로 Authorization 헤더에 Bearer + token 형태로 사용된다.

Base64로 표현 되어 있고 인코딩, 디코딩이 쉽다.

 

Base64란?

64진법 이라는 뜻.

8비트 이진 데이터를 ASCII 영역의 문자로 바꾸는 인코딩 방식.

 

구성 방식

토큰의 구조는 다음과 같다.

  1. Header : 토큰의 타입이나 어떤 알고리즘으로 생성되었는지 저장한다. 
  2. Payload : 사용자, 토큰에 대한 정보를 저장한다. (표준은 키를 3글자로 지정한다.)
  3. Signature : Header와 Payload 값을 서버에 있는 개인 키를 활용해 암호화해 저장한다. (위, 변조 방지용)

 

작동 방식

  1. 최초 로그인 시 서버에서 토큰을 사용자에게 발급
  2. 사용자는 다음 요청부터 Authorization 헤더에 발급받은 토큰을 넣어 요청을 보냄
  3. 서버는 해당 토큰을 검증(사용자 정보, 권한, 만료기간 등)을 확인하여 토큰이 유효하면 접근을 허가함

 

왜 사용하는가?

세션&쿠키 방식은 이용자가 늘어나면 메모리 사용량이 그만큼 늘어나게 되며,

매 요청마다 세션 저장소를 조회해야한다는 단점이 있었다.

 

JWT는 토큰 자체에 사용자 정보를 저장한다.

따라서 서버는 요청이 들어오면 토큰을 확인하는 로직만 수행하면 되므로

이용자가 늘어나도 session 방식과 다르게 서버에 부하가 적다.

 

그러나 단점도 존재하는데

  1. 쿠키&세션 방식보다 데이터 전달량이 많다
  2. Payload는 암호화되어있지 않으므로, 디코딩이 매우 쉽다.
  3. 발급한 토큰을 서버에서 관리할 수 없으므로 토큰 탈취시 대처가 어렵다.

따라서 토큰 생성시 민감한 정보는 절대로 넣으면 안되며

Secure, Http Only 등의 설정 잘 해줘야한다.

 

또한 발급한 토큰은 더 이상 서버에서 관리 못한다는 단점 때문에 로그아웃, 접근 제한 등이 어렵다.

이러한 문제를 해결하기 위해

  • 블랙리스트 방식
  • 리프레쉬 토큰 방식

을 주로 사용한다.

 

블랙리스트 방식

db, 혹은 서버 메모리에 허용하지 않을 토큰 명단을 생성후

해당 토큰이 들어오면 허가를 하지 않는 방식.

 

그러나 JWT의 장점인 stateless 하다, 서버 자원 사용이 적다는 장점이 사라지게 된다.

 

리프레쉬 토큰 방식

토큰의 탈취 가능성을 생각해 토큰의 유효기간을 매우 짧게 설정한 후 (엑세스 토큰)

유효기간이 긴 리프레쉬 토큰을 같이 발급해, 리프레쉬 토큰이 유효할 경우 토큰을 재발급 하는 방식.

 

리프레쉬 토큰은 발급 후 redis 같은 곳에 저장해서 관리한다.

(리프레쉬 토큰이 탈취당했을 경우 db에서 삭제 등으로 대응 가능)

 

액세스 토큰이 들어올 경우 확인만 하면 되므로 기존 JWT 장점 살리며

토큰이 탈취돼어도 길게 사용하지 못하도록 해 보안성을 높인다.

 

리프레쉬 토큰을 저장하고 사용하면 세션방식과 다를게 뭔가? 라는 생각이 들었지만

세션 방식은 매 요청마다 저장소를 확인하지만, 리프레쉬 토큰은 액세스 토큰이 만료되었을때만 확인하면 된다.

 

보통은 리프레쉬 토큰 방식을 사용하여 관리하며

보안이 중요한 경우 블랙리스트 방식을 같이 사용한다.

 

JWT 방식을 반드시 사용해야할까?

공부하면서 느낀점은 사실 JWT를 사용할 필요가 있을까 싶다.

요청이 몰리는 경우가 많은 서비스 등의 경우가 아닌 이상

그냥 세션 방식 써도 될 것 같다.

 

기존 세션 방식의 문제점인 서버 자원 사용량 증가 등의 문제는

Redis를 이용한 세션 클러스터링등으로 해결이 가능해진 것 같다.

 

은탄환은 없다라는 유명한 말이 있다.

상황에 맞춰서 적절한 방식을 사용하자.

 

Spring Security를 사용해 JWT 방식을 적용한 코드는 다음 글에서 써보겠다.

기존 프로젝트는 유저 이름과 비밀번호를 DB에 저장한 후,

스프링 인터셉터와 httpSession을 이용해 인증과 인가를 구현했다.

 

DB에 저장한 아이디, 비밀번호와 세션을 사용하는 기존 프로젝트의 인증, 인가 방식을 그대로 유지하며

Spring Security를 적용해보았다.

 

1. build.gradle 에 Spring Security 추가

dependencies {
    .
    .
    .
    
    //Spring Security 추가
    implementation 'org.springframework.boot:spring-boot-starter-security'
    testImplementation 'org.springframework.security:spring-security-test'
}

2. 보안 설정 -  SecurityFilterChain 설정하기

저번 글에서 설명했던 SecurityFilterChain 이라는 스프링 빈을 통해서 인증, 인가, 보안과 관련된 설정들을 해줘야한다.

스프링부트 3.0.0 이하 버전은 WebSecurityConfigurerAdapter를 상속받는 설정 클래스를 만들면 된다.

@Configuration
@EnableWebSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter {
    
    @Override
    protected void configure(HttpSecurity http) throws Exception {
        
        // http.csrf().disable(); // 1. csrf 설정 끄기 (api일 경우에 사용)
        
        http.authorizeRequests() // 2. url 매핑 설정
                .antMatchers("/admin").permitAll() // 기존 초기화면 url
                .antMatchers("/admin/users/create-users").permitAll() // 기존 회원가입 url
                .antMatchers("/error/**").permitAll() // 기존 에러화면 url
                .anyRequest().authenticated()
            .and()
                .formLogin() // 3. 로그인 폼 설정 
                .defaultSuccessUrl("/admin") //로그인 성공 시 url
                .failureUrl("/admin/login"); //로그인 실패 시 url
        
        http.logout()
            .logoutUrl("/admin/logout");   // 4. 로그아웃 처리 URL
    }
}

설정 크래스를 만든 후 HttpSecurity를 파라미터로 받는 configure 메소드를 오버라이딩 해서 설정을 진행하면 된다.

 

1. csrf 설정

csrf 공격을 막는 설정이다.

rest api 등 form을 지원하지 않는 상황일 경우 꺼두면 된다.

 

2. 적용 url 설정

permitAll(), authenticated() 등을 이용해서 url에 따른 인증 조건을 설정할 수 있다.

기존 프로젝트의 url을 최대한 재활용하도록 설정했다.

 

3. 로그인 폼 설정

로그인 폼 방식으로 인증을 진행할 것이라는 설정을 formLogin()을 이용해 설정했다.

기본 설정값으로 /login url에 미리 만들어진 로그인 폼을 제공한다.

로그인 폼을 직접 만든 html으로 설정하거나,

로그인 폼의 파라미터 값을 변경하는 등의 설정을 바꿀 수 있다.

 

일단은 로그인 성공, 실패시 이동 url만 따로 지정줬다.

 

4. 로그아웃 처리 설정

로그아웃 처리 시 사용할 url을 직접 지정할 수 있다.

 

더 다양한 설정을 할 수 있으니 궁금하면 더 찾아보자

3. UserDetails, UserDetailsService 상속받아 구현하기

우리는 username/password 인증 방식을 사용하고, DB에 저장되어있는 ID, 비밀번호를 이용해 인증을 진행해야한다.

Spring Security는 username/password 방식 인증을 위해 UserDetailsService와 UserDetails를 이용한다.

 

UserDetails는 사용자의 정보를 담는 인터페이스다.

UserDetailsService는 인증을 위해 유저 정보(UserDetails)를 가져오는 인터페이스이다.

 

기존 프로젝트의 DB와 코드를 재활용하며 사용하기 위해

기존 domain 엔티티였던 User 객체가 UserDetails를 상속 받도록,

기존 회원가입을 관련 로직을 수행하던 userService 객체가 UserDetailsService를 상속받도록 구현했다.

 

UserDetails를 상속받기 위해 구현해야할 메서드는 다음과 같다.

메소드 이름 리턴 타입 설명
getAuthorities() Collection<? extends GrantedAuthority> 계정의 권한 목록 반환
getUsername() String 계정의 식별자(아이디, 이메일) 반환
getPassword() String 계정 비밀번호 반환
isAccountNonExpired() boolean 계정 만료 여부 반환
isAccountNonLocked() boolean 계정 잠김 여부 반환
isCredentialNonExpired() boolean 계정 비밀번호 만료 여부 반환
isEnabled() boolean 계정 사용 가능 여부 반환

 

@Entity
@Table(name = "users")
@Getter
@NoArgsConstructor
public class User implements UserDetails{

    @Column(unique = true)
    private String accountId;
    ...
    
    @OneToMany(mappedBy = "user", cascade = CascadeType.ALL, orphanRemoval = true)
    private List<UserRole> userRoles = new ArrayList<>();
    
    ...
    
    //<==security==>//
    public Collection<? extends GrantedAuthority> getAuthorities(){
        return this.userRoles.stream()
            .map(ur -> new SimpleGrantedAuthority(ur.getRoleName()))
            .collect(toList());
    }

    public String getUsername(){
        return this.accountId; // 식별자 반환
    }

    public boolean isAccountNonExpired(){ // 나머지 설정은 기본적으로 true로 설정
        return true;
    }
    ...
    ...
}
@Entity
@Table(name = "user_roles")
@Getter
@NoArgsConstructor
public class UserRole {
    private String roleName;
    
    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "user_id")
    private User user;
    
    public UserRole(String roleName){
        this.roleName = roleName;
    }
}

유저의 접근 권한 설정을 위해 userRole라는 객체를 만든 후 다대일 관계로 설정해줬다.

또한 유저 권한을 받는 getAuthorities()는 유저의 접근 권한 이름을 리스트로 반환하도록 구현했다.

 

@Service
@RequiredArgsConstructor
public class UserServiceImpl implements UserService, UserDetailsService{
    
    private final UserRepository userRepository;
	...
    ...
    
    //<== security 설정 ==> //
    @Transactional(readOnly = true)
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException{
        
        User findUser = userRepository.findByAccountId(username)
            .orElseThrow(NoSuchUserException::new); 
            
        return findUser;
    }
}

UserDetailsService는 식별자(String username)을 파라미터로 받아

UserDetails를 반환하는 loadUserByUsername 메소드를 구현하면 된다. 

 

4. PasswordEncoder 및 UserDetailsService 설정하기

기존 프로젝트의 큰 문제점 중 하나는 DB에 사용자 비밀번호를 그대로 사용한다는 것이었다.

DB에 사용자 비밀번호와 이메일 등 민감한 정보를 그대로 사용하면,

DB가 털릴 경우 민감한 정보가 모두 유출될 수 있다는 위험성이 존재한다.

 

그래서 Spring Security는 문자열 암호화 기능을 제공하는 PasswordEncoder 인터페이스를 제공한다.

PasswordEncoder를 구현하는 인코더를 스프링 빈으로 설정하면

AuthentcationProvider에서 사용하도록 설정할 수 있다.

@Configuration
@EnableWebSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter {
    @Bean
    public BCryptPasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();
    }
    
    @Override
    protected void configure(HttpSecurity http) throws Exception {
    	...
        ...
	}
}

UserDetailsService를 구현한 UserService는 @Service 어노테이션이 있기 때문에

자동으로 컴포넌트 스캔 대상이 되므로 따로 등록할 필요는 없다.

 

구현 과정에서 겪은 오류와 이슈들

1. OSIV 이슈

org.hibernate.LazyInitializationException: 
failed to lazily initialize a collection of role:
project.domain.User.userRoles, could not initialize proxy - no Session

구현 후 서버를 돌렸더니 Lazy Loding 관련 에러가 발생했다.

UserDetailsService에서 꺼내온 UserDetails 객체의 권한 정보를 LazyLoding하는 과정에서 오류가 발생했다.

 

정확히는 UserDetailsService에서 UserDatails를 가져와 AuthenticationProvider에 넘겨주면

AuthenticationProvider는 UserDatails 객체를 이용해 Authentication 객체를 만드는데

권한 정보를 확인하기 위해 UserDatails 객채의 getAuthorities() 메소드를 실행할때

UserDetailsService에서 만든 트랜잭션 밖에서 조회했으므로 LazyLoding이 발생했다.

    //<== security 설정 ==> //
    @Transactional(readOnly = true)
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException{
        
        User findUser = userRepository.findByAccountId(username)
            .orElseThrow(NoSuchUserException::new); 
        
        //userRole lazy loding
        findUser.getUserRoles().size();
        
        return findUser;
    }

간단하게 서비스 트랜잭션 안에서 userRoles 인증 객체들을 초기화해줌으로서 해결했다.

 

그런데 왜 OSIV를 true 설정해줬는데도 서비스 로직 밖까지 트랜잭션이 유지되지 않은걸까?

그 이유는 OSIV를 관리하는 설정이 스프링 인터셉터 기반이기 때문이다.

public class OpenEntityManagerInViewInterceptor extends ... {
    ...
    @Override
    public void preHandle(WebRequest request) throws DataAccessException {
        ...
        logger.debug("Opening JPA EntityManager in OpenEntityManagerInViewInterceptor");
        try {
            EntityManager em = createEntityManager();
            EntityManagerHolder emHolder = new EntityManagerHolder(em);
            TransactionSynchronizationManager.bindResource(emf, emHolder);
        ...
    }

	@Override
	public void afterCompletion(WebRequest request, @Nullable Exception ex) throws DataAccessException {
        ...
        logger.debug("Closing JPA EntityManager in OpenEntityManagerInViewInterceptor");
	EntityManagerFactoryUtils.closeEntityManager(emHolder.getEntityManager());
    }
    ...
}

스프링 부트에서 OSIV를 설정해주는 OpenEntityManagerInViewInterceptor 인터셉터의 구현 코드이다.

preHandle에서 EntityManager를 생성하고, afterCompletion에서 EntityManager를 닫는다.

 

그러나 스프링 시큐리티의 보안 로직은 서블릿 필터를 기반으로 동작한다.

그러므로 스프링 인터셉터로 EntityManager를 열어두기 전에 이미 필터에서 로직을 처리중이었다.

 

https://tecoble.techcourse.co.kr/post/2020-09-20-entity-lifecycle-2/

 

Entity Lifecycle을 고려해 코드를 작성하자 2편

이번 편에서는 전편에서 해결하지 못한 부분이었던 “Spring Boot에서는 기본적으로 OSIV의 설정 값이 true인데도 불구하고 LazyInitializationException…

tecoble.techcourse.co.kr

인터셉터의 실행 순서를 바꿔서 해결하는 방법도 존재하는 것 같아서 가져와봤다.

2. CSRF 토큰 에러와 th:action

설정에서 csrf.disable()을 제거 (csrf 보안 설정을 활성화) 했더니 로그인과 로그아웃 로직에서 에러가 발생했다.

spring security는 csrf 공격을 csrf 토큰을 자동으로 생성해 발급해주는 방식으로 예방한다.

 

내 html form에 csrf 토큰 전달 input이 존재하지 않아서 발생하는 오류 인듯해서

만들어둔 post 메소드를 진행해봤더니 유저 수정 post 메소드만 정상 동작해서 뇌정지가 왔다.

 

같은 post메소드인데 왜 하나는 되고 나머지는 안될까?

그 이유는 th:action 태그에 있었다.

<!-- create form -->
<form role="form" action="..." method="post" ...> 

<!-- update form -->
<form role="form" th:action="..." method="post" ...>

update form은 타임리프의 th:action 태그를 사용했는데

타임리프의 th:action 태그는 폼 태그에 자동으로 다음과 같은 scrf 토큰값을 hidden 태그로 추가해 전달해준다고 한다.

<input type="hidden" th:name="${_csrf.parameterName}" th:value="${_csrf.token}">

나머지 로직에 히든 태그를 추가하거나, th:action 태그를 사용해 해결할 수 있었다..

 

3. UserDetails 직접 구현하는 방식이 맞는가

이번에 적용한 코드에선 기존에 사용하던 도메인 객체인 User가 UserDetails를 구현하도록 수정했다.

그런데 도메인 객체가 스프링 시큐리티에 의존해도 되는가? 라는 의문이 발생했다.

 

나중에 spring security가 아니라 직접 만든 보안 로직을 적용해야한다면?

도메인 객체를 변경하는 비용은 상상을 초월할텐데.. 라는 생각이 들었다.

 

그런데 중간 레이어를 구현하기엔 코드와 클래스 양이 너무 많고 복잡해진다는 생각도 들었다.

그런데 생각해보니까 기존 도메인 객체도 JPA에 의존하고 있었다.

 

이 부분은 클린 아키텍처에 대해 조금 더 공부해봐야 할 것 같다.

 

구현해보면서 느낀 점 - Spring Security를 꼭 사용해야만 할까?

 

작은 프로젝트는 스프링 인터셉터를 이용해 직접 구현하는 것이 더 편할지도 모르겠다는 생각이 들었다.

일단 기본적인 구조를 이해하고 적용해보는데만 5일이 넘게 걸렸다.

그리고 아직도 username/password 방식이 아닌 jwt, oauth 방식등에 대한 공부는 더 해봐야한다.

 

그러나 Spring security는 csrf 토큰 방식 등 직접 구현하기 귀찮은 다양한 보안 기능들을 편하게 제공한다.

또 인증 인가 메커니즘을 jwt, ouath로 변경해야하는 상황이 생긴다면 직접 구현한 코드는 처음부터 다시 구현해야하지만,

스프링 시큐리티는 설정만 조금 변경하면 될 것 같다.

 

프로젝트 규모에 따라 사용할지 말지를 잘 결정해야할 것 같은데

잘 공부해서 사용하면 굉장히 유용할 것 같다.

+ Recent posts