좋은 설계란 무엇인가?

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

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

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

 

하지만 이러한 고민을 기존에 하던 스프링 부트&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 로그인도 공부해봐야겠다.

네트워킹이란 두 대 이상의 컴퓨터를 케이블로 연결하여 네트워크를 구성하는 것을 말한다.

이 장에서는 가장 기본적인 네트워킹 예제들과 채팅 어플리케이션을 작성할 수 있을 정도 수준의 내용을 다룬다.

 

클라이언트/서버

컴퓨터간의 관계를 역할로 구분하는 개념이라고 이야기한다.

서버는 서비스를 제공하는 컴퓨터(service provider) 이고, 클라이언트는 서비스를 사용하는 컴퓨터가 된다.

 

서버에 접속하는 클라이언트에 따라 하나의 서버가 여러 서비스를 제공하기도 하고,

하나의 서비스를 여러 대의 서버가 제공하기도 한다.

 

네트워크를 구성할 때 전용 서버를 두는 것을 서버 기반 모델(server-based-model)이라고 하며

별도의 전용 서버 없이 각 클라이언트가 서버 역할을 동시에 수행하는 것을 P2P 모델(peer-to-peer)이라 한다.

 

IP주소

IP주소는 컴퓨터를 구별하는데 사용되는 고유의 값이다. 인터넷에 연결된 컴퓨터는 모두 IP 주소를 가진다.

IP주소를 표현하는 프로토콜은 IPv4 IPv6가 있으며 IPv4에서 IP주소는 4byte의 정수로 구성된다.

 

IP주소는 다시 네트워크 주소와 호스트 주소로 나뉘어 있는데, 네트워크를 어떻게 구성하였는지에 따라 달라진다.

서로 다른 두 호스트의 IP주소의 네트워크 주소가 갇다는 것은 두 호스트가 같은 네트워크에 포함되어 있다는 것을 의미한다.

 

자바는 IP주소를 다루기 위한 클래스로 InetAddress를 제공한다.

 

URL(Uniform Resource Locator)

URL은 인터넷에 존재하는 여러 서버들이 제공하는 자원에 접근할 수 있는 주소를 표현하기 위한 것이다.

자바는 URL을 다루기 위한 클래스로 URL이라는 이름의 클래스를 제공한다.

 

URL 클래스는 해당 url에 대한 정보와 URL에 의해 참조된 리소스에 연결한 스트림을 제공한다.

String address = "<https://www.대충프로젝트url.com/users>";
String line = "";
        
try{
	URL url = new URL(address);
	BufferedReader input = new BufferedReader(new InputStreamReader(url.openStream()));            
            
	while((line = input.readLine()) != null){
		System.out.println(line);    
	}
		input.close();
	} catch(Exception e) {
		e.printStackTrace();
	}
}

 

URLConnection

URLConnection은 어플리케이션과 URL간의 통신연결을 나타내는 클래스의 최상위 추상 클래스이다.

HttpURLConnection 과 JarURLConnection과 같은 구현 클래스가 있다.

 

URL 객체를 생성한 후 url.getConnection() 메서드 사용시 지정된 URL에 대한 소켓을 열고 URLConnection 객체를 반환해준다.

URLConnection을 이용해 연결된 URL에 의해 참조된 리소스에 대한 정보를 얻을 수 있다.

(http 헤더값, content, 만료일자, 캐쉬 사용여부 등)

 

소켓 프로그래밍

소켓을 이용한 통신 프로그래밍을 뜻한다. 소켓이란 프로세스간의 통신에 사용되는 양쪽 끝단(end point)를 의미한다.

자바는 java.net 패키지를 통해 소켓 프로그래밍을 지원하는데, 소켓통신에 사용하는 프로토콜에 따라 다른 종류의 소켓을 구현하여 제공한다.

 

TCP와 UDP

TCP/IP 프로토콜은 이기종 시스템간의 통신을 위한 표준 프로토콜이다.

TCP와 UDP 모두 TCP/IP 프로토콜에 포함되어 있으며 OSI7계층의 전송 계층에 해당하는 프로토콜이다.

TCP와 UDP는 전송방식에 차이가 있으며 방식에 따른 장단점이 존재한다.

  • TCP : 연결기반 통신, 신뢰성 있는 데이터 전송(전송 순서 보장, 수신여부 확인), 느린 속도
  • UDP : 비연결기반 통신, 신뢰성 없는 데이터 전송(순서 보장 x, 수신여부 확인 x), 빠른 속도

 

TCP 소켓 프로그래밍

자바는 TCP를 이용한 소켓 프로그래밍을 위해 Socket, ServerSocket 클래스를 제공한다.

ServerSocket 객체가 포트와 연결되어 외부의 요청을 기다리다가,

요청이 들어오면 Socket 객체를 생성해 클라이언트 프로그램의 소켓과 연결한 후 통신이 이루어지게 한다.

Socket 클래스는 프로세스간의 통신을 담당하며 InputStream, OutputStream을 가지고 있다.

 

UDP 소켓 프로그래밍

UDP 소켓 프로그래밍은 DatagramSocket과 DatagramPacket을 사용한다.

UDP는 연결지향적 프로토콜이 아니므로 ServerSocket이 필요없다.

 

DatagramSocket에서 데이터를 DatagramPacket에 담아 전송한다.

DatagramPacket은 헤더와 데이터로 구성되어 있으며, 헤더에는 DatagramPacket을 수신할 호스트이 정보가 저장되어 있다.

패킷을 전송하면 패킷에 지정된 주소의 소켓에 도착한다.

 

 

마지막장은 짧기도 하고 예시 위주의 내용이 대부분이라 가볍게 보고 넘겼다.

네트워크에 관련된 공부는 나중에 따로 더 해봐야 할 것 같다.

입출력이란?

I/O 는 Input, Output의 약자로 입력과 출력을 의미한다.

입출력은 컴퓨터 내부 또는 외부의 장치와 프로그램간의 데이터를 주고 받는 것을 말한다.

 

스트림(stream)

원래 모니터랑 키보드라고 쓰려했는데 이제 보니까 모니터가 2개다. 그냥 듀얼 모니터를 쓴다고 생각하자.

 

자바에서 입출력을 수행하기 위해선, 두 대상을 연결하고 데이터를 전송할 수 있는 통로가 필요하다.

이를 스트림이라고 한다. 이 스트림은 전 장의 스트림 API와는 완전히 다른 개념이다.

 

스트림은 연속적인 데이터의 흐름을 흐르는 물에 비유해 붙여진 이름이라고 한다.

물이 한쪽으로만 흐르듯이, 스트림은 단방향 통신만 지원하며, 먼저 보낸 데이터를 먼저 받고,

중간에 건너뜀 없이 연속적으로 데이터를 주고받는다. (큐 형태)

 

따라서 한 방향으로만 데이터를 전송할 수 있으므로 입출력을 동시해 수행하려면 입력 스트림과, 출력 스트림, 총 2개의 스트림이 필요하다.

 

바이트 기반 스트림

바이트기반 스트림은 바이트단위로 데이터를 전송하며 입출력 대상에 따라 다음과 같은 입출력 스트림들이 있다.

  • FileInputStream , FileOutputStream : 파일 입출력
  • ByteArrayInputStream, ByteArrayOutputStream : 메모리(byte 배열)
  • PipedInputStream, PipedOutputStream : 프로세스간의 통신
  • AudioInputStream, AudioOutputStream : 오디오장치

그리고 이들은 모두 InputStream과 OutputStream의 자손 클래스들이다.

자바에서는 java.io 패키지를 통해 많은 종류의 입출력 클래스들을 제공하며, 입출력을 처리하는 표준화된 방법을 제공한다.

//InputStream
abstract int read()
int read(byte[] b)
int read(byte[] b, int off, int len)

//OutputStream
abstract void write(int b)
void write(byte[] b)
void write(byte[] b, int off, int len)

InputStream의 read(), write(int b)는 입출력의 대상에 따라 읽고 쓰는 방법이 달라져야 하기 때문에 추상 메서드로 정의되어 있다.

 

그리고 나머지 read, write 메서드들은 추상 메서드인 read(), write(int b)를 이용해 구현한 것이기 때문에

read(), write(int b)가 구현되어 있지 않으면 의미가 없다.

 

https://docs.oracle.com/javase/tutorial/essential/io/bytestreams.html

 

Byte Streams (The Java™ Tutorials > Essential Java Classes > Basic I/O)

The Java Tutorials have been written for JDK 8. Examples and practices described in this page don't take advantage of improvements introduced in later releases and might use technology no longer available. See Java Language Changes for a summary of updated

docs.oracle.com

import java.io.FileInputStream;
import java.io.FileOutputStream;
import java.io.IOException;

public class CopyBytes {
    public static void main(String[] args) throws IOException {

        FileInputStream in = null;
        FileOutputStream out = null;

        try {
            in = new FileInputStream("xanadu.txt");
            out = new FileOutputStream("outagain.txt");
            int c;

            while ((c = in.read()) != -1) {
                out.write(c);
            }
        } finally {
            if (in != null) {
                in.close();
            }
            if (out != null) {
                out.close();
            }
        }
    }
}

공식 문서의 사용 예시이다.

파일 입출력을 위해 FileInputStream, FileOutputStream을 사용했다.

 

read()의 반환값이 int인 이유는 읽기에 실패했을 경우 -1을 반환하기 때문이다.

 

스트림을 사용한 후에는 close() 메서드를 이용해 스트림을 닫아주어야한다.

JVM이 프로그램이 종료될때 닫지 않은 스트림을 닫아둔다고는 하나, 무조건 close()로 닫아주는 것이 좋다고 한다.

(System.in, System.out 같은 표준 입출력 스트림은 닫아주지 않아도 된다.)

 

공식문서에서는 close()를 호출하는 것이 매우 중요하다고 이야기하며,

예시에선 finally문을 이용해 예외가 발생하더라도 close()가 호출되도록 했다.

이렇게 close()를 반드시 호출해야 심각한 메모리 누수를 피할 수 있다고 이야기한다.

 

Memory Leak(메모리 누수)란?

어떤 객체가 더 이상 사용하지 않지만 GC에 의해 삭제되지 않아 계속해서 메모리에 남아있는 현상이다.

이러한 누수가 커질 경우 OutOfMemoryError의 원인이 되기도 한다.

 

스트림을 사용한 후 close()를 사용하지 않을 경우 메모리 누수의 원인이 될 수 있다.

 

문자기반 스트림

바이트기반 스트림이 바이트를 단위로 입출력했다면 문자기반 스트림은 char 단위로 입출력한다.

바이트기반 스트림의 조상이 InputStream/OutputStream이듯이 문자기반 스트림의 조상은 Reader/Writer 이다.

 

문자기반 스트림은 char 단위로 입출력하는 것 외에도

여러 종류의 인코딩과 자바에서 사용하는 유니코드(UTF-16)간의 변환을 자동으로 처리해준다.

Reader는 특정 인코딩을 읽어 유니코드로 변환하고, Writer는 유니코드 특정 인코딩으로 변환해 저장한다.

 

문자 데이터를 다루는데 사용된다는 것을 제외하면 바이트기반 스트림과 사용방법이 거의 같다.

import java.io.FileReader;
import java.io.FileWriter;
import java.io.BufferedReader;
import java.io.PrintWriter;
import java.io.IOException;

public class CopyLines {
    public static void main(String[] args) throws IOException {

        BufferedReader inputStream = null;
        PrintWriter outputStream = null;

        try {
            inputStream = new BufferedReader(new FileReader("xanadu.txt"));
            outputStream = new PrintWriter(new FileWriter("characteroutput.txt"));

            String l;
            while ((l = inputStream.readLine()) != null) {
                outputStream.println(l);
            }
        } finally {
            if (inputStream != null) {
                inputStream.close();
            }
            if (outputStream != null) {
                outputStream.close();
            }
        }
    }
}

 

보조 스트림

이러한 스트림들 외에도 스트림의 기능을 보완하기 위한 보조 스트림이 제공된다.

 

보조 스트림은 실제 데이터를 주고받는 스트림이 아니기 때문에 보조 스트림 만으로 데이터를 입출력할 수는 없지만,

스트림의 기능을 향상시키거나 새로운 기능을 추가할 수 있다.

 

스트림을 먼저 생성한 후에 이를 매개변수로 넣어 보조 스트림을 생성할 수 있다.

FileInputStream fis = new FileInputStream("test.txt");

BufferedInputStream bis = new BufferedInputStream(fis);
bis.read();

 

보조스트림의 종류

  • BufferedInputStream/BufferedOutputStream : 입출력에 버퍼를 지원한다.
  • DataInputStream/DataOutputStream : 데이터를 읽고 쓰는데 byte가 아닌 원시 자료형의 단위를 지원한다.
  • SequenceInputStream : 여러개의 입력스트림을 연결해 하나의 스트림처럼 사용할 수 있게 해준다.
  • PrintStream : 데이터를 다양한 형태로 출력할 수 있는 print, println, printf같은 메서드를 오버라이딩해 제공한다.
  • InputStreamReader/OutputStreamWriter : 바이트스트림을 문자기반 스트림으로 연결해준다.

이러한 보조 스트림들의 조상은 FilterInputStream/FilterOutputStream이다.

보통은 스트림에 버퍼(Buffer)를 제공하기 위해 사용된다.

 

버퍼(Buffer)란?

데이터를 전송하는 저,고속 장치간의 속도차이를 줄여주는 역할을 하는 중간 저장소이다.

데이터를 한번에 전달하는 것이 아니라 묶어서 전달한다.

 

하드디스크, 키보드, 모니터 등의 외부장치의 속도는 매우 느리다.

키보드가 눌릴때마다 CPU로 정보를 이동시키는 것보다,

중간에 Buffer를 둬서 데이터를 묶어뒀다가 한번에 이동시키는 것이 효율적이고 빠르다.

 

표준입출력

표준입출력은 콘솔(console, 도스창)을 통한 데이터 입력과 콘솔로의 데이터 출력을 의미한다.

자바에서는 표준 입출력을 위해 System.in, System.out, System.err 3가지 입출력 스트림을 제공한다.

 

이 3가지 스트림은 자바 어플리케이션 실행과 동시에 자동으로 사용 가능하도록 생성되며,

종료시에 닫히므로 별도로 생성하는 코드를 작성하거나 close()를 호출해 닫아줄 필요가 없다.

 

이들은 System 클래스의 스태틱 변수이며, BufferedInputStream, BufferedOutputStream의 인스턴스를 사용해서 구현되어 있다.

setIn(), setOut(), setErr() 메서드를 통해 입출력 대상을 콘솔 화면에서 다른 곳으로 변경할 수 있다.

 

File

파일은 가장 기본적이면서 가장 많이 사용되는 입출력 대상이기 때문에,

자바에서는 File 클래스를 만들어 파일과 디렉토리를 다룰 수 있도록 만들었다.

File(String fileName) //주어진 문자열을 이름으로 갖는 파일을 위한 인스턴스를 생성한다.

static String pathSeparator // OS에서 사용하는 경로 구분자 Windows ";"  UNIX":"
static char pathSeparatorChar
static String separator // OS에서 사용하는 이름 구분자 Windows "\\" UNIX "/"
static char separatorcChar

File 인스턴스는 파일일 수도 있고, 디렉터리일 수도 있다.

File 클래스의 생성자에 들어가는 fileName은 주로 경로를 포함해서 지정해주며,

파일 이름만 사용할 시 프로그램의 실행 위치가 경로로 간주된다.

 

OS마다 파일의 경로 구분자가 다르기 때문에 직접 입력해서 사용할 시 특정 OS에선 오류를 일으킬 수 있다.

OS에 독립적으로 프로그램을 작성하기 위해선 File 클래스에서 제공하는 static 변수를 사용하자.

 

직렬화(Serialization)

직렬화란 객체를 데이터 스트림으로 만드는 것을 뜻한다.

즉 객체에 저장된 데이터를 스트림에 쓰기 위한 연속적인 데이터로 변환하는 것을 이야기한다.

반대로 스트림에서 데이터를 읽어 객체를 만드는 것을 역직렬화라고 이야기한다.

 

ObjectInputStream, ObjectOutputStream

각각 역직렬화, 직렬화에 사용하는 스트림이다.

기반 스트림을 필요로 하는 보조 스트림이므로, 객체 생성시에 입출력을 위한 스트림을 지정해줘야한다.

ObjectInputStream(InputStream in)
ObjectOutputStream(OutputStream out)

 

만약 파일에 객체를 저장하고 싶다면 다음과 같이 하면 된다.

FileOutputStream fos = new FileOutputStream("objectfile.txt");
ObjectOutputStream out = new ObjectOutputStream(fos);
ObjectInputStream in = new ObjectInputStream(fos);

out.writeObject(new User());
User u = (User) in.readObject();

writeObject(), readObject()를 통해 객체를 저장하고 읽어올 수 있다.

readObject()의 반환타입은 Object이므로, 원래 타입으로 형변환을 해줘야한다.

 

객체를 직렬화, 역직렬화하는 과정은 객체의 모든 인스턴스변수가 참조하는 모든 객체에 관한 내용이기 때문에,

상당히 복잡하고 시간도 오래 걸린다.

직렬화 작업 시간을 단축시키기 위해서는 직렬화하고자 하는 객체의 클래스에 다음과 같은 2개의 메서드를 구현해줘야한다.

private void writeObject(ObjectOutputStream out)

private void readObject(ObjectInputStream in)

 

Serializable, transient

java.io.Serializable 인터페이스를 구현하면 직렬화가 가능한 클래스가 된다.

Serializable 인터페이스는 아무 내용도 없는 빈 인터페이스이지만, 직렬화를 고려하여 작성한 클래스인지 판단하는 기준이 된다.

class User implements Serializable{
  private int age;
  private String name;
  private MyClass myClass; //직렬화 불가능
}

class MyClass{}

다음과 같은 User 클래스의 인스턴스를 직렬화하면 객체의 인스턴스 변수들이 직렬화 대상이 된다.

만약 인스턴스 변수 중 직렬화가 불가능한 클래스가 있다면 직렬화 시에 NotSerializableException이 발생한다.

class User implements Serializable{
  private int age;
  private String name;
  private Object myClass = new MyClassSerializable(); //직렬화 가능!

  transient private Object object = new Object(); // 직렬화 -> null
}

class MyClassSerializable implements Serializable{}

직렬화가 가능한지는 인스턴스 변수의 타입이 아닌 실제 연결된 객체의 종류에 의해 결정된다.

위와 같은 코드처럼 Object 타입 인스턴스 변수를 선언해도 실제 값이 직렬화 가능한 객체라면 직렬화가 가능하다.

 

또한 직렬화가 안되는 객체에 대한 참조를 포함하고 있다면 transient를 붙여서 직렬화 대상에서 제외할 수 있다.

transient가 붙은 인스턴스의 값은 그 타입의 기본값으로 직렬화된다.

 

직렬화가능한 클래스의 버전관리

직렬화된 객체를 역직렬화할 때는 직렬화 했을때와 같은 클래스를 사용해야한다.

그러나 클래스의 이름이 같더라도, 내용이 변경된 경우 역직렬화는 실패하며 예외가 발생한다.

 

객체가 직렬화될 때 객체는 클래스에 정의된 멤버들의 정보를 이용해서

serialVersionUID라는 클래스의 버전에 관한 식별자(UID)를 자동생성해서 직렬화 내용에 포함한다.

역직렬화시에 UID가 다르다면 이는 클래스의 내용이 수정되었다는 뜻이므로 에러를 발생시킨다.

 

그러나 static 변수, 상수, 또는 transient가 붙은 인스턴스 변수가 추가되는 경우 직렬화에는 영향을 끼치지 않는다.

이 경우 static final long serialVersionUID라는 스태틱 변수를 직접 추가해 버전을 수동으로 관리할 수 있다.

스트림이란?

많은 수의 데이터를 다룰 때, 컬렉션이나 배열에 데이터를 담고 원하는 결과를 얻기 위해

for문이나 Iterator를 이용해 코드를 작성해왔다.

 

그러나 이러한 코드는 너무 길고, 알아보기 힘들며, 재사용성이 떨어진다.

또한 데이터 소스마다 다른 방식으로 데이터를 다뤄야한다.

 

이러한 문제점을 해결하기 위해 만든 것이 스트림이다.

스트림은 데이터 소스를 추상화하고, 데이터를 다루는데 자주 사용되는 메서드들을 정의해놓았다.

String[] strArr = {"aaa", "ccc", "bbb"};
List<String> strList = Arrays.asList(strArr);

Stream<String> stream1 = Arrays.stream(strArr); //스트림 생성
Stream<String> stream2 = strList.stream(); //스트림 생성

 

두 스트림으로 데이터를 정렬하고 화면에 출력하는 방법은 다음과 같다.

stream1.sorted().forEach(System.out::println);
stream2.sorted().forEach(System.out::println);

 

두 스트림의 데이터 소스는 다르지만, 정렬하고 출력하는 방법이 완전히 동일해졌다.

또한 코드가 간결하고 이해하기 쉬워졌다.

 

스트림의 특징

1. 스트림은 데이터 소스를 변경하지 않는다.

스트림은 데이터 소스로부터 데이터를 읽기만 할 뿐, 데이터 소스를 변경하지 않는다.

String[] strArr = {"aaa", "ccc", "bbb"};
Stream<String> stream1 = Arrays.stream(strArr); //스트림 생성

stream1.sorted().forEach(System.out::println);

for(String s : strArr){
  System.out.println(s); // aaa ccc bbb
}

필요하다면 정렬된 결과를 컬렉션이나 배열에 다시 담아 반환할 수 있다.

List<String> sortedList = stream1.sorted().collect(Collectors.toList());

 

2. 스트림은 일회용이다.

stream1.sorted().forEach(System.out::println);

int num = stream1.count(); //에러. 스트림이 이미 닫혔음

스트림은 Iterator처럼 일회용이다. 스트림도 한 번 사용한 후에는 다시 사용할 수 없다.

필요하다면 스트림을 다시 생성해야한다.

 

3. 스트림은 작업을 내부 반복으로 처리한다.

스트림은 반복문을 메서드 내부로 숨길수 있다.

forEach()메서드는 매개변수에 대입된 람다식을 데이터 소스 요소에 적용하는데

메서드 안에 for문을 넣어놓은 것이다.(수행할 작업을 람다식 매개변수로 받는다)

 

스트림의 연산

스트림은 다양한 연산을 제공해, 복잡한 작업들을 간단히 처리할 수 있다.

 

스트림이 제공하는 연산은 중간 연산최종 연산으로 구분할 수 있다.

 

중간 연산은 연산 결과를 스트림으로 반환한다. 따라서 중간 연산을 연속해서 연결할 수 있다.

최종 연산은 연산 결과가 스트림이 아니다. 스트림의 요소를 소모해 연산을 수행해 한 번 만 가능하다.

stream.distinct().limit(5).sorted().forEach(System.out::println);

distinct(), limit(), sorted()가 중간연산이며, forEach()가 최종 연산이라고 할 수 있다.

 

지연된 연산

스트림에서 중요한 점은, 최종 연산이 수행되기 전까지 중간 연산이 수행되지 않는다는 것이다.

이게 뭔소리냐면, distinct(), sort()같은 중간 연산이 호출되도 즉각적으로 수행되는 것이 아니다.

 

중간 연산을 호출하는 것은, 어떤 작업을 수행해야하는지를 지정해주는 것이다.

최종 연산이 호출되어야 비로소 스트림의 요소들이 중간 연산을 거쳐 최종 연산에서 소모된다.

 

Stream<Integer>과 IntStream

요소의 타입이 T인 스트림은 기본적으로 Stream<T>이지만, 오토박싱&언박싱은 비효율적이기 때문에

기본형을 다루는 스트림 IntStream, LongStream, DoubleStream이 제공된다.

 

이러한 기본형 스트림을 Stream으로 바꾸거나 반대로 연산하려면

mapToInt(), boxed()와 같은 형변환 메서드를 사용해야한다.

 

병렬 스트림

스트림으로 데이터를 다룰때 얻는 또 다른 장점은 병렬 처리가 쉽다는 것이다.

스트림에 parallel()이라는 메서드를 호출하면 병렬로 연산을 수행하도록 할 수 있다.

int sum = stream1.parallel()
	.mapToInt(s -> s.length())
	.sum()

그러나 병렬처리가 항상 더 빠른 결과를 얻게 해주는 것이 아니라는 것을 명심하자.

 

스트림 만들기

컬렉션은 최고 조상인 Collection에 stream()이 정이되어 있다.

Collection과 자손인 List, Set을 구현한 클래스들은 모두 이 메서드로 스트림을 생성할 수 있다.

List<String> strList = new ArrayList<> ();

Stream<String> stream = strList.stream(); //스트림 생성

 

배열을 소스로 하는 스트림을 생성하는 메서드는

다음과 같이 Stream과 Arrays에 static 메서드로 정의되어 있다.

Stream<T> Stream.of(T... values)
Stream<T> Stream.of(T[])
Stream<T> Arrays.stream(T[])
Stream<T> Arrays.stream(T[] array, int startInclusive, int endExclusive)

 

특정 범위의 정수로 스트림 만들기

IntStream.range(int begin, int end) //end 미포함
IntStream.rangeClosed(int begin, int end) //end도 포함

 

임의의 수로 스트림 만들기

난수를 생성하는데 사용되는 Random 클래스에는 아래와 같은 인스턴스 메서드들이 포함되어 있다.

IntStream ints()
LongStream longs()
DoubleStream doubles()

 

이렇게 생성한 스트림은 크기가 정해지지 않은 무한 스트림이므로 limit()을 사용해서 크기를 제한해줘야한다.

또는 메서드에 매개변수를 넣어서 유한 스트림을 생성할수도 있다.

IntStream stream = Random().ints().limit(5).forEach(System.out::println);
IntStream stream = Random().ints(5);

 

람다식 - iterate(), generate()

static <T> stream<T> iterate(T seed, UnaryOperator<T> f)
static <T> stream<T> generate(Supplier<T> s)

iterate()는 seed로 지정된 값부터 시작해, 람다식 f에 의해 계산된 결과를 반복하는 무한 스트림을 생성한다.

generate()도 iterate()처럼 람다식에 의해 반복되는 값을 요소로 하는 무한 스트림을 생성하지만

iterate()처럼 이전 결과로 다음 요소를 계산하지 않는다.

Stream<Integer> evenStream = Stream.iterate(0, n->n+2); //0, 2, 4, 6, 8, ...
Stream<Integer> oneStream = Stream.genarate(() -> 1); //1, 1, 1, 1, 1, ...

 

빈 스트림

요소가 하나도 없는 비어있는 스트림을 생성하는 것도 가능하다.

책에선 스트림에 연산을 수행한 결과가 하나도 없다면, null보다 빈 스트림을 반환하는 것이 더 낫다고 이야기한다.

Stream emptyStream = Stream.empty();
long count = emptyStream.count() //0

 

두 스트림의 연결

Stream의 static 메서드인 concat()을 사용하면, 같은 타입의 요소를 가지는 두 스트림을 하나로 연결할 수 있다.

Stream<String> stream1 = Stream.of("aaa", "bbb", "ccc");
Stream<String> stream2 = Stream.of("ddd", "eee", "fff");
Stream<String> stream3 = Stream.concat(stream1, stream2); //두 스트림 하나로 합침

 

스트림의 중간 연산

skip(), limit()

skip()과 limit()은 스트림의 일부를 잘라낼 때 사용한다.

skip(int n)은 처음 n개의 요소를 건너뛰며, limit(int n)은 맨 앞 n개만 가져온다

IntStream stream1 = IntStream.range(1,10); //1~9까지 요소 가짐
IntStream stream2 = stream1.skip(3); // 맨앞 3개 제외, 4~9까지
IntStream stream3 = stream1.limit(5) // 1~5까지

 

filter(), distinct()

distinct()는 스트림에서 중복된 요소를 걸러내며, filter()는 주어진 조건에 맞지 않는 요소를 걸러낸다.

Stream<Integer> stream = Stream.generate(() -> 1); //1, 1, 1, 1, ...
stream.limit(10).distinct().forEach(System.out::println); // 중복 제거 -> 1

IntStream stream = IntStream.range(1,10); //1, 2, 3, ...8, 9
stream.limit(10).filter(n -> n%2 == 0).forEach(System.out::println); //2 4 6 8

sorted()

스트림을 정렬할 때는 sorted()를 사용하면 된다.

sorted()는 지정된 Comparator로 스트림을 정렬하고, int를 반환하는 Comparator를 직접 지정해 사용하는 것도 가능하다.

List<User> userList = new ArrayList<> ();
userList.add(new User(1, "aaa", UserGrade.BASIC));
userList.add(new User(2, "bbb", UserGrade.BASIC));
userList.add(new User(3, "ccc", UserGrade.VIP));
userList.add(new User(4, "aaa", UserGrade.BASIC));
        
userList.stream().sorted(Comparator.comparing(User::getGrade)
	.thenComparing(User::getId)
	.thenComparing(User::getName))
	.forEach(System.out::println);

 

map()

스트림의 요소에 저장된 값 중에서 원하는 필드만 뽑아내거나 특정 형태로 변환해야 할 때가 있다.

이때 map() 메서드를 사용하면 편하게 변환할 수 있다.

Stream<String> names = userList.stream().map(u -> u.getName());

flatMap()

Stream<T[]> 를 Stream<T>로 변환해준다.

스트림의 요소가 배열일 경우, 그냥 요소들을 다 꺼내 스트림으로 다루는 것이 더 편할때가 있는데 그럴때 flatMap()을 사용한다.

 

peek()

연산과 연산 사이에 올바르게 처리되었는지 확인하고 싶다면 peek()를 사용하자.

forEach문과 달리 스트림의 요소를 소모하지 않는다. 즉 연산 사이에 여러번 사용해도 문제가 되지 않는다.

사용법은 forEach와 똑같다.

 

스트림의 최종연산

최종 연산은 스트림의 요소를 소모해서 결과를 만들어낸다. 따라서 최종 연산 후에는 스트림을 더 이상 사용할 수 없다.

최종 연산의 결과는 스트링 요소의 합 같은 단일값 또는 스트림의 요소가 담긴 배열, 컬렉션 등이 나올 수 있다.

(forEach(), count()는 위에서 계속 사용했으므로 생략한다.)

 

조건 검사 - allMatch(), anyMatch(), noneMatch(), findFirst(), findAny()

스트림의 요소에 대해 지정된 조건에 모든 요소가 일치하는지, 일부가 일치하는지, 아니면 어떤 요소도 일치하지 않는지 확인하는데 사용할 수 있는 메서드들이다.

boolean allMatch(Predicate<? super T> predicate)
boolean anyMatch(Predicate<? super T> predicate)
boolean noneMatch(Predicate<? super T> predicate)

boolean basicUsers = userList.stream().anyMatch(u -> u.getGrade() == UserGrade.BASIC)

allMatch(), anyMatch(), noneMatch() 메서드는 boolean 값을 반환한다.

스트림의 요소를 확인하며 모두 조건을 만족하는지, 만족하는 요소가 있는지, 아무것도 만족하지 않는지 확인한다.

Optional<User> findUser = userList.stream()
	.filter(u -> u.getName() == "kim")
	.findFirst();

findFirst()는 스트림의 요소중에서 조건에 맞는 첫번째 요소를 반환한다.

주로 filter와 함께 사용되며, 병렬 스트림의 경우 findAny()를 사용한다.

 

reduce()

reduce라는 이름에 맞체, 스트림의 요소를 점차 줄여나가면서 연산을 수행해 최종 결과를 반환한다.

Optinal<T> reduce(BinaryOperator<T> accumulator)
T reduce(T identity, BinaryOperator<T> accumulator)

스트림의 처음 두 요소를 가지고 연산을 수행해 나온 값으로 다음 값과 연산을 수행한다.

스트림의 모든 요소를 소모하면 결과를 반환한다.

매개변수로 초기값을 설정해주는 것도 가능하다.

IntStream stream = IntStream.range(1,10);
OptionalInt sum = stream.reduce((a,b) -> a+b);
System.out.println(sum.getAsInt());

collect()

스트림의 최종 연산중에서 가장 복잡하지만 유용하게 활용될 수 있는 연산이다.

  • collect() : 최종 연산 메서드, 매개변수로 collector를 필요로한다.
  • Collector : 인터페이스, collector 매개변수로 들어가는 collecotr는 이 인터페이스를 구현해야한다.
  • Collectors : 클래스, static 메서드로 미리 작성된 collector를 제공한다.

collect()는 스트림의 요소를 수집하는 최종 연산으로 앞서 배운 reducing과 유사하다고 이야기한다.

collect()가 요소를 수집할때는 어떻게 수집할 것인지 방법을 정의해줘야하는데, 이것이 collector이다.

collector는 Collectors 인터페이스를 구현한 것으로, 직접 구현하거나, 구현된 것을 사용할 수도 있다.

 

스트림을 컬렉션과 배열로 변환

toList(), toSet(), toMap(), toCollection(), toArray() 메서드는 Collectors의 static 메서드로

스트림을 각각의 컬렉션과 배열로 변환해준다.

List<String> userNameList = userList.stream()
	.map(User::getName)
	.collect(Collectors.toList())

ArrayList<User> list = userList.stream()
	.collect(Collectors.toCollection(ArrayList::new));

 

List나 Set이 아닌 특정 컬렉션을 지정하려면 toCollection에 해당 컬랙션의 생성자 참조를 매개변수로 넣어주면 된다.

Map은 키와 값의 쌍으로 저장해야하므로, 객체의 어떤 필드를 키로 사용할지와 값으로 사용할지를 넣어줘야한다.

Map<Integer,User> userMap = userList.stream()
                             .collect(Collectors.toMap(u -> u.getId(), u->u));

 

joining()

문자열 스트림의 모든 요소를 하나의 문자열로 결합해서 반환한다.

구분자를 지정할 수도 있고, 접두사와 접미사도 지정가능하다.

스트림 요소가 String일때만 결합이 가능하므로, map을 통해 변환해줘야 한다.

userList.stream()
	.map(User::getName)
	.collect(Collectors.joining(",")); //구분자 지정

userList.stream()
	.map(User::getName)
	.collect(Collectors.joining("," , "[" , "]")); //구분자, 접두사, 접미사

 

통계 - counting(), summing(), averagingint(), maxBy(), minBy()

int count = userStream.count();
int count = userString.collect(Collectors.counting());

int totalScore = userStream.mapToInt(User::getScore()).sum();
int totalScore = userStream.collect(summingInt(User::getScore));

count(), sum(), average() 처럼 collect()와 결합하여 사용하지 않는 최종연산으로도 다음의 통계 기능들을 수행할 수 있다. 이 메서드들은 후술할 groupingBy()와 함께 사용할때 주로 사용된다.

 

groupingBy(), partitioningBy()

그룹화는 스트림의 요소를 특정 기준으로 그룹화하는 것을 의미하며,

분할은 스트림의 요소를 지정된 조건에 일치하는 그룹과, 일치하지 않는 그룹으로 나누는 것을 의미한다.

 

그룹화와 분할의 결과는 Map에 담겨 반환된다.

책에선 스트림을 두 개의 그룹으로 나눠야한다면 partitioningBy()가 더 빠르며 그 외에는 groupingBy()를 쓰라고 이야기한다.

Map<Boolean, List<User>> userByVIP = userStream
	.collect(Collectors.partitioningBy(u -> u.getGrade() == UserGrade.VIP));

List<User> vipUserList = userByVIP.get(true);
List<User> basicUserList = userByVIP.get(false);

Map<Boolean, Set<User>> userByVIP = userList.stream().collect(
Collectors.partitioningBy(u -> u.getGrade() == UserGrade.VIP,
							Collectors.toSet()));

collect() 안에 partitioningBy(조건식)을 넣으면 List<T> 형태로 맵에 저장되어 나온다.

기본은 List 형식이지만 Collectors.toSet(), toCollection()등의 메서드로 다른 형태로도 저장할 수 있다.

 

partitioningBy에 조건식 외에도 통계 메서드를 넣어 사용할 수도 있다.

Map<Boolean, Long> userByVIP = userStream
	.collect(Collectors.partitioningBy(u -> u.getGrade() == UserGrade.VIP ,
		Collectors.counting()));

만약 UserGrade 값이 BASIC, VIP처럼 2개가 아닌 BRONZE, SILVER, GOLD 처럼 여러개라면 groupingBy()를 사용하자.

Map<UserGrade, List<User>> userByGrade = userList.stream()
            .collect(Collectors.groupingBy(User::getGrade));

System.out.println(userByGrade.get(UserGrade.GOLD));

Collector 구현하기

지금까지 Collectors 클래스가 제공하는 collector를 사용했지만.

우리가 직접 collector를 구현해서 사용하는 것도 가능하다.

Collector 인터페이스는 다음과 같이 정의되어 있다.

public interface Collector<T, A, R>{
	Supplier<A> supplier();
	BiConsumer<A, T> accmulator();
  BinaryOperator<A> combiner();
  Function<A,R> finisher();

  Set<Characteristics> characteristics(); //컬렉터의 특성이 담긴 set 반환
}
  • supplier() : 작업 결과를 저장할 공간 제공
  • accumlator() : 스트림 요소를 수집할 방법을 제공
  • combiner() : 두 저장공간을 병합할 방법을 제공 (병렬 스트림)
  • finisher() : 결과를 최종적으로 반환할 방법 제공

characteristics()는 컬렉터가 수행하는 작업의 속성에 대한 정보를 제공하기 위한 것으로

  • Characteristics.CONCURRENT : 병렬로 처리 가능한 작업
  • Characteristics.UNORDERED : 스트림의 요소가 순서가 유지될 필요가 없는 작업
  • Characteristics.IDENEITY_FINISH : finisher()가 항등함수인 작업

위의 3가지 요소중 해당되는 것을 set에 담아서 반환하도록 구현하면 되며

아무 요소도 넣고 싶지 않을 경우 Collections.emptySet()을 반환하면 된다.

람다식

람다식은 간단히 말해 메서드를 하나의 식으로 표현한 것이다. JDK1.8부터 추가되었다.

메서드를 람다식으로 표현하면 메서드의 이름과 반환값이 없어지므로, 람다식을 ‘익명 함수’라고도 한다.

 

람다식 작성하기

 

람다식은 ‘익명 함수’답게 메서드에서 이름과 반환타입을 제거하고 매개변수 선언부와 {} 사이에 - >를 추가한다.

  1. 반환값이 있는 메서드의 경우 return문 대신 식(expression)으로 대체할 수 있다.
    (연산 결과가 자동으로 반환값이 되며, 문장이 아닌 ‘식’ 이므로 ; 를 붙이지 않는다)
  2. 매개변수의 타입이 추론 가능한 경우 생략할 수 있다. (대부분의 경우에 생략 가능하다.)
  3. 매개변수가 하나뿐인 경우에는 괄호를 생략할 수 있다. (매개변수 타입이 존재하면 생략 불가능)
public String hello(String name){
  return "hello" + name;
}

//반환타입과 메서드 이름을 생략한다.
(String name) -> {return "hello" + name;}

//return 문을 식(expression)으로 대체한다
(String name) -> "hello" + name

//매개변수 타입을 생략한다
(name) -> "hello" + name

//괄호 생략
name -> "hello" + name

 

함수형 인터페이스

자바의 모든 메서드는 클래스 내에 포함되어야 하는데, 람다식은 어떤 클래스에 포함되는 것일까?

앞에서 람다식이 메서드와 동등한 것처럼 이야기했지만, 사실 람다식은 익명 클래스의 객체와 동등하다.

name -> "hello" + name

new Object() { //위의 람다식과 동등함
  public String hello(String name){ //함수 이름은 임의로 붙인 것
    return "hello" + name;
  }
}

 

그렇다면 람다식으로 정의된 익명 객체의 메서드를 어떻게 호출할 수 있을까?

참조변수가 존재해야 instance.hello() 처럼 메서드를 호출할 수 있다.

그렇다면 instance의 타입은 어떤 것이어야 할까?

 

바로 람다식과 동등한 메서드가 정의되어 있는 클래스 또는 인터페이스가 있어야 한다.

interface MyFunction {
    public abstract String hello(String name);
}

 

 

이 인터페이스를 구현한 익명 클래스의 객체는 다음과 같이 생성하고 사용할 수 있다.

MyFunction f = new MyFunction() {
  public String hello(String name){
    return "hello" + name;
  }
};

System.out.println(f.hello("kim")); //hellokim

 

그리고 이 익명 객체의 메서드 hello()를 람다식으로 변환할 수 있다.

MyFunction f = name -> "hello" + name; //익명 객체를 람다식으로 대체

System.out.println(f.hello("kim")); //hellokim

이처럼 인터페이스를 구현한 객체를 람다식으로 대체가 가능한 이유는,

람다식도 실제로는 익명 객체이며, MyFunction인터페이스를 구현한 익명 객체의 메서드 hello()와

람다식의 매개변수의 타입, 개수, 반환값이 일치하기 때문이다.

 

하나의 메서드가 선언된 인터페이스를 정의해 람다식을 다루는 것은 기존의 자바의 규칙을 어기지 않으면서도 자연스럽다.

그래서 인터페이스를 통해 람다식을 다루기로 결정되었으며,

람다식을 다루기 위한 인터페이스를 함수형 인터페이스(functional interface)라고 부르기로 했다.

 

함수형 인터페이스에는 오직 하나의 추상 메서드만 정의되어 있어야 한다. (default와 static은 괜찮다)

그래야 람다식과 인터페이스의 메서드가 1:1로 연결될 수 있기 때문이다.

 

함수형 인터페이스 타입의 매개변수와 반환타입

함수형 인터페이스 MyFunction이 정의되어 있을때 어떤 클래스의 메서드 매개변수가 MyFunction 타입이라면

이 메서드를 호출할때 람다식을 참조하는 참조변수를 매개변수로 지정해야한다는 것이다.

@FunctionalInterface
interface MyFunction {
    public abstract String hello(String name);
}

class MyClass{
    public void methodHello(MyFunction f){ //매개변수에 함수형 인터페이스
        System.out.println(f.hello("kim"));
    }
}

MyClass mc = new MyClass();
MyFunction f = name -> "hello" + name;

mc.methodHello(f); //매개변수에 람다식 참조값 지정

또는 참조변수 없이 아래와 같이 직접 람다식을 지정하는 것도 가능하다.

mc.methodHello(name -> "hello" + name); //변수처럼 람다식 사용

람다식을 참조변수로 다룰 수 있다는 것은 메서드를 통해 람다식을 주고받을 수 있다는 것이다.

즉 람다식을 변수처럼 주고받는 것이 가능해진 것이다.

 

람다식의 타입과 형변환

함수형 인터페이스로 람다식을 참조할 수 있을 뿐,

람다식의 타입이 함수형 인터페이스와 일치하는 것은 아니다.

람다식은 익명 객체이며, 익명 객체는 컴파일러가 임의로 이름을 정하기 때문에 타입을 알 수 없다.

MyFunction f = (MyFunction) name -> "hello" + name;

그래서 사실 대입 연산자 사용 전에 형변환을 해줘야한다.

 

람다식은 MyFunction 인터페이스를 직접 구현하지 않았지만,

인터페이스를 구현한 익명 객체와 완전히 동일하기 때문에 위와 같은 형변환을 허용한다.

그리고 이러한 형변환은 생략이 가능하다.

 

그런데 람다식을 그냥 Object로 형변환하려하면 실패한다.

람다식은 오직 함수형 인터페이스로만 형변환이 가능하기 때문이다.

Object o = (Object) name -> "hello" + name; //에러
Object o = (Object) (MyFunction) name -> "hello" + name; //OK

따라서 굳이굳이 바꾸고 싶다면 두번째 문장처럼 두번의 형변환을 거쳐야한다.

 

외부 변수를 참조하는 람다식

람다식도 익명 객체, 즉 익명 클래스의 인스턴스이다.

람다식에서 외부에 선언된 변수에 접근하는 규칙은 익명 클래스에서와 동일하다.

void method(){
    int n = 1;

    MyFunction mf = i -> i + n++; //오류 발생
}

다음과 같이 람다식 외부의 변수를 변경하려 하면 오류가 발생한다.

익명 클래스의 메서드 내에서 사용되는 지역 변수는 모두 final이어야만 한다.

 

이는 만약 익명 클래스 내에서 새로운 쓰레드를 생성하고 변수를 참조하기 전에

기존 메서드가 종료되어 지역변수가 소멸된다면, 참조할 값이 없어지기 때문이다.

 

그래서 익명 클래스에서 외부 지역변수를 사용할땐 값을 복사해서 사용하는데,

복사된 값이 최신값인지 확인할 수 없으므로 값이 보장되지 않고, 매번 실행 결과가 달라질 수 있다.

 

final 변수는 JVM이 constant pool에서 관리해 메서드가 종료되도 바로 소멸되지 않고 관리되므로

중간에 기존 메서드가 종료되어도 참조가 가능하며, 복사 이후에 값이 바뀔 걱정을 하지 않아도 된다.

 

그래서 익명 클래스에서 사용할 외부 변수는 final이거나 effectively final이어야 한다.

(만약 어떤 변수 생성 및 초기화 후에 값을 바꾸지 않으면 JVM이 변수를 final로 바꿔 해석하는데 이를 effectively final이라 한다.)

 

 

java.util.function 패키지

사실 대부분의 메서드는 타입이 비슷하다. 매개변수가 없거나 1개 혹은 2개이며, 반환 값은 없거나 한개이다.

여기에 제네릭을 사용하면 매개변수나 반환 타입이 매번 달라져도 문제가 되지 않는다.

 

그래서 java.util.function 패키지에 자주 쓰이는 형식의 메서드를 함수형 인터페이스로 미리 정의해놓았다.

  • Runnable - void run() : 매개변수도 없고, 반환값도 없음
  • Supplier<T> - T get() : 매개변수는 없고, 반환값만 있음
  • Consumer<T> - void accept(T t) : 반환값이 없고, 매개변수만 있음
  • Function<T,R> - R apply(T t) : 하나의 매개변수를 받아서 결과 반환
  • Predicate<T> - boolean test(T t) : 하나의 매개변수를 받아서 boolean 반환

저기의 Runnable은 우리가 아는 java.lang.Runnable 맞다.

이 패키지를 사용하면 기존의 우리가 선언했던 MyFunction 함수형 인터페이스를 사용하지 않아도 된다.

class MyClass{
    public void methodHello(Function<String,String> f){ //MyFunction 대신 Function 사용
        System.out.println(f.apply("kim"));
    }
}

MyClass mc = new MyClass();
mc.methodHello(name -> "hello" + name);

 

그리고 사실 Runnable 역시 굳이 구현 클래스를 만드는 것이 아니라 람다식으로 작성해도 된다,

Thread t = new Thread( () -> {
            System.out.println(Thread.currentThread().getName());    
        });
        
t.start();

 

매개변수가 두 개인 함수형 인터페이스는 이름 앞에 접두사 Bi 가 붙는다.

  • BiConsumer<T,U> - void accept(T t, U u)
  • BiPredicate<T,U> - boolean test(T t, U u)
  • BiFunction<T,U,R> R apply(T t, U u)

만약 두 개 이상의 매개변수를 갖는 함수형 인터페이스가 필요하다면 직접 만들어서 써야 된다.

 

UnaryOperator과 BinaryOperator

Function의 변형으로 매개변수의 타입과 반환 타입이 일치할때 사용한다.

  • UnaryOperator<T> - T apply(T t)
  • BinaryOperator<T> - T apply(T t1, T t2)

UnaryOperator를 사용해 위의 코드를 수정할 수 있다.

public void methodHello(UnaryOperator<String> f){ //Function<String,String> 에서 변경
    System.out.println(f.apply("kim"));
}

 

기본형을 사용하는 함수형 인터페이스

지금까지 소개한 함수형 인터페이스는 매개변수와 반환값이 제네릭 타입이다.

만약 기본형 타입의 값을 처리하고 싶다면?

래퍼 클래스를 사용해도 되지만, 기본형 대신 래퍼 클래스를 사용하는 것은 비효율적이다.

 

그래서 기본형을 사용하는 함수형 인터페이스도 제공된다.

  • DoubleTolnutFunction - int applyAsInt(double d) : AToBFunction은 입력이 A 출력이 B 타입
  • ToIntFunction<T> - int applyAsInt(T value) : ToBFunction은 출력이 B타입, 입력은 제네릭
  • IntFunction<R> - R apply(int i) : AFunction 은 입력이 A 타입, 출력이 제네릭
  • ObjIntConsumer - void accept(int i) : ObjA타입은 입력이 A 타입이고 출력은 없다.

Function의 합성과 Predicate의 결합

앞서 소개한 java.util.function 패키지 안에는 추상메서드 외에 default 메서드와 static 메서드가 정의되어 있다.

책에서는 Function과 Perdicate에 정의된 메서드만 짚고 넘어간다.

 

Function의 합성

수학에서 두 함수를 합성해 새로운 함수를 만들듯이, 두 람다식을 합성해 새로운 람다식을 만들 수 있다.

함수 f, g 가 있을때

  • f.andThen(g) : f를 먼저 적용하고 나온 값을 g에 적용한다.
  • f.compose(g) : g를 먼저 적용하고 나온 값을 f에 적용한다.
class MyClass{
    public void binaryString(Function<String,String> f){
        System.out.println(f.apply("16"));
    }
}

Function <String, Integer> f = (s) -> Integer.parseInt(s,16);
Function <Integer, String> g = (i) -> Integer.toBinaryString(i);
        
MyClass mc = new MyClass();
mc.binaryString(f.andThen(g));

 

Predicate의 결합

여러 조건식을 논리 연산자로 연결해서 하나의 식을 만들듯이

여러 Predicate 역시 and(), or(), negate() (부정 연산자)로 연결해 하나의 새로운 Predicate로 결합할 수 있다.

 

메서드 참조

람다식이 하나의 메서드만 호출하는 경우 ‘메서드 참조’라는 방법으로 람다식을 생략할 수 있다.

Function<String, Integer> f = (String s) -> Integer.parseInt(s);

Function<String, Integer> f = Integer::parseInt; //메서드 참조

참조변수 f의 타입만 봐도 String이 들어와 Integer이 반환된다는 것을 알기 때문에

매개변수로 들어온 값이 메서드에 그대로 사용될 것을 안다면 :: 기호만 사용하고 나머지를 생략할 수 있다.

 

이미 생성된 특정 객체의 인스턴스 메서드를 참조할 경우, obj::method 처럼

객체 참조변수::메서드 이름으로도 사용할 수 있다.

 

생성자의 메서드 참조

생성자를 호출하는 람다식도 메서드 참조로 변환할 수 있다.

Supplier<MyClass> s = () -> new MyClass();

Supplier<MyClass> s = MyClass::new;

메서드 참조는 람다식을 마치 static 변수처럼 다룰 수 있게 해준다.

코드를 간략히 하는데 유용해서 많이 사용되므로 잘 기억해두자.

+ Recent posts