[리팩터링] 6장 데이터 보호

공효은·2023년 5월 14일
1

refactoring

목록 보기
6/12
post-thumbnail
post-custom-banner

이번 장에서 다룰 내용

  • getter와 setter를 사용하지 말 것으로 캡슐화 강제하기
  • getter와 setter 제거하기로 getter 제거
  • 공통 접사를 사용하지 말 것을 위한 데이터 캡슐화 사용
  • 순서 강제화로 불변속성 제거

클래스는 동일한 데이터에 대한 기능을 결합해서 불변속성을 더 가깝게 모아 지역화한다.
이번 장에서는 데이터와 기능에 대한 접근을 제한하는 캡슐화에 초첨을 맞춰 불변속성이 지역에만 영향을 주게 만드는 데 중점을 둔다.

6.1 getter 없이 캡슐화하기

6.1.1 규칙: getter와 setter를 사용하지 말것

정의

부울(Boolean)이 아닌 필드에 setter나 getter를 사용 하지 말라

설명

  • 여기서 setter 또는 getter를 언급할 때는 각각 부울이 아닌 필드를 직접할당하거나 반환하는 메서드

  • getter와 setter 는 흔히 private 필드를 다루기 위한 메서드로 캡슐화와 함께 배운다. 그러나 필드에 대한 getter가 존재하는 순간 캡슐화를 해제하고 불변속성을 전역적으로 만들게 된다.
    객체를 반환한 후 이를 반환받은 곳에서는 이 객체를 더 많은 곳에 전달할 수 있으며 이는 우리가 제어할 수 없다. 객체를 얻은 어느 곳에서나 public 메서드를 호출할 수 있으며 아마도 예상하지 못한 방식으로 객체를 수정할 수 있다.

  • setter도 비슷한 문제를 제기한다. 이론적으로 setter는 내부 데이터 구조를 변경하고 해당 setter를 수정해도 시그니처(signature)를 유지 할 수 있는 또 다른 간접적인 레이어를 도입할 수 있다. (이것은 setter가 아니므로 문제 되지 않음)

  • 그러나 실제로 일어나는 일은, setter를 통한 새로운 데이터 구조를 반환하도록 getter를 수정하는 것이다. 그런다음 수신자 측에서 이 새로운 데이터 구조를 받을 수 있도록 수정해야한다. 이것은 정확히 우리가 피하고 싶어하는 밀 결합(tight coupling)의 형태이다.

  • 필드를 비공개로 하는 것에 장점은 그렇게 하는 것이 푸시기반의 아키텍처를 장려하기 때문 푸시기반 아키텍처에서는 가능한 한 데이터에 가깝게 연산을 이관한다.

  • 풀 기반의 아키텍처에서는 데이터를 가져와 중앙에서 연산을 수행한다.

  • 푸시 기반 아키텍처에서는 데이터를 가져오는 대신 인자로 데이터를 전달한다. 결과적으로 모든 클래스가 자신의 기능을 가지고 있으며 코드는 그 효용에 따라 분산된다.

예제는 블로그 게시물에 대한 링크를 생성한다.

// 풀 기반 아키텍처
class Website {
  constructor (private url:string){ }
  getUrl() {return this.url;}
}
class User {
  constructor (private username: string){ }
  getUsername() {return this.username;}
}
class blogPost {
  constructor (private author: User, private id: string){ }
  getId() {return this.id;}
  getAuthor() {return this.author;}
}
function generatePostLink(website: Website, post:BlogPost){
  let url = website.getUrl();
  let user = post.getAuthor();
  let name = user.getUsername();
  let postId = post.getId();
  return url + name + postId;
}
// 푸시 기반 아키텍처
class Website {
  constructor(private url:string) { }
  generateLink(name: string, id:string){
    return this.url + name + id;
  }               
}
class User {
  constructor (private username:string) { }
  generateLink(website: Website, id:string){
    return website.generateLink(this.uername,id);
  }
}
class BlogPost {
  constructor (private author: User, private id:string){ }
  getnergateList(website:Website){
    return this.author.generateLink(website, this.id)
  }
}
function generatePostLink(website, WebSite, post: BlogPost){
  return post.generateLink(website);
}

푸시 기반 예제에는 덧붙여진 정보 없이 한 줄에 불과하기 때문에 generatePostLink를 인라인화할 가능성이 높다.

6.1.2 규칙 적용하기

  1. getRemoveStrategy를 비공개로 설정하면 사용하는 모듯 곳에서 오류가 발생한다.
// 변경 전
class KeyConfiguration {
  // ...
  getRemoveStrategy() {
    return this.removeStrategy;
  }
}

// 변경 후
class KeyConfiguration {
  // ...
  private getRemoveStrategy() {
    return this.removeStrategy; 
  }
}
  1. 오류를 수정하려면 오류가 발생한 줄에서 클래스로의 코드이관을 사용한다.
// 변경 전
class Key implements Tile {
  // ...
  moveHorizontal(dx:number){
    remove(this.keyConf.getRemoveStrategy());
    moveToTile(playerx + dx, player);
  }
  moveVertical(dy: number){
    remove(this.keyConf.getRemoveStrategy());
    moveToTile(playerx, playery + dy);
  }
}
class KeyConfiguration {
  // ...
  getRemoveStrategy() {
    return this.removeStrategy;
  }
}

// 변경 후
class Key implements Tile {
  // ...
  moveHorizontal(dx:number){
    this.keyConf.removeLock(); 
    moveToTile(playerx + dx, player);
  }
  moveVertical(dy: number){
    this.keyConf.removeLock();
    moveToTile(playerx, playery + dy);
  }
}
class KeyConfiguration {
  // ...
  removeLock(){
    remove(this.removeStrategy);
  }
}
  1. getRemoveStrategy는 클래스로의 코드 이관의 일부로 인라인화 되었다. 따라서 이제 사용되지 않기 때문에 다른 사람이 사용하지 못하게 삭제한다.
//변경 전
class KeyConfiguration{
  // ...
  private getRemoveStrategy() {
    return this.removeStrategy;
  }
}

//변경 후 
class KeyConfiguration {
}

getColor에 대해 이 절차를 반복하면 다음과 같다.

//변경 전
class KeyConfiguration {
  constructor(
    private color: string,
    private _1:boolean,
    privage removeStrategy:RemoveStrategy
  ){ }
  getColor() {return this.color;}
  _1() {return this._1};
  getRemoveStrategy(){
    return this.removeStrategy;
  }
}

class Key implements Tile {
 constructor(
  private keyConf: KeyConfiguration
 )
 }{ }
//...
  draw(g: CanvasRenderingContext2D,x:number,y:number){
    g.fillStyle =this.keyConf.getColor();
    g.fillRect(x * TILE_SIZE, y * TILE_SIZE, TILE_SIZE, TILE_SIZE);
  }
  
  moveHorizontal(dx:number){
    remove(this.keyConf.getRemoveStrategy());
    moveToTile(playerx + dx, playery);
  }
  moveVertical(dy: number){
    remove(this.keyConf.getRemoveStrategy());
    moveToTile(playerx, playery + dy);
  }
}

class Lock implements Tile {
 constructor(
   private keyConf: KeyConfiguration
  ){ }
  //...
  isLock1(){return this.keyConf.is1();}
  isLock2(){return !this.keyConf.is1();}
  draw(g: CanvasRenderingContext2D,x:number,y:number){
    g.fillStyle =this.keyConf.getColor();
    g.fillRect(x * TILE_SIZE, y * TILE_SIZE, TILE_SIZE, TILE_SIZE);
  }
}

// 변경 후
class KeyConfiguration {
  constructor(
    private color: string,
    private _1:boolean,
    privage removeStrategy:RemoveStrategy
  ){ }
 setColor(g: CanvasRenderingContxt2D){
   g.fillStyle = this.color;
 }
 removeLock() {
   remove(this.removeStrategy);
 }
  _1() {return this._1};

}

class Key implements Tile {
 constructor(
  private keyConf: KeyConfiguration
 )
 }{ }
//...
  draw(g: CanvasRenderingContext2D,x:number,y:number){
    this.keyConf.setColor(g);
    g.fillRect(x * TILE_SIZE, y * TILE_SIZE, TILE_SIZE, TILE_SIZE);
  }
  
  moveHorizontal(dx:number){
    this.keyConf.removeLock();
    moveToTile(playerx + dx, playery);
  }
  moveVertical(dy: number){
    this.keyConf.removeLock();
    moveToTile(playerx, playery + dy);
  }
}

class Lock implements Tile {
 constructor(
   private keyConf: KeyConfiguration
  ){ }
  //...
  isLock1(){return this.keyConf.is1();}
  isLock2(){return !this.keyConf.is1();}
  draw(g: CanvasRenderingContext2D,x:number,y:number){
   	this.keyConf.setColor(g);
    g.fillRect(x * TILE_SIZE, y * TILE_SIZE, TILE_SIZE, TILE_SIZE);
  }
}

6.1.3 리팩터링 패턴: getter와 setter 제거하기

절차

  1. getter 또는 setter가 사용되는 모든 곳에서 오류가 발생하도록 비공개로 설정한다.
  2. 클래스로의 코드 이관으로 오류를 수정한다.
  3. getter 또는 setter는 클래스로의 코드 이관의 일부로 일라인화 된다. 따라서 사용하지 않으므로 삭제해서 다른 사람이 사용하지 않게 한다.

예제

  1. getter 또는 setter가 사용되는 모든 곳에서 오류가 발생하도록 비공개로 설정한다.
// 변경 전
class BlogPost {
  // ...
  getAuthor() {
    rteurn this.author;
  }
}

// 변경 후
class BlogPost {
  private getAuthor(){ //private 키워드 추가
    return this.author;
  }
}
  1. 클래스로의 코드 이관으로 오류를 수정한다.
// 변경 전
function generatePostLink(website:Website, post:BlogPost){
  let url = website.getUrl();
  let user = post.getAuthor();
  let name = user.getUsername();
  let postId = post.getId();
  return url + name + postId;
}

class BlogPost {
  // ...
}

// 변경 후
function generatePostLink(website:Website, post:BlogPost){
  let url = website.getUrl();
 
  let name = post.getAuthorname();
  let postId = post.getId();
  return url + name + postId;
}

class BlogPost {
  // ...
  getAuthorName() {return this.author.getUsername();
  }
}
  1. getter는 클래스로의 코드 이관의 일부로 인라인화된다.따라서 사용하지 않으므로 다른 사람이 사용하지 못하게 삭제한다.
//변경 전
class BlogPost {
  //...
  private getAuthor(){
    return this.author;
  }
}
//변경 후
class BlogPost {
  //...
  // getAuthor가 삭제됨
}

6.1.4 마지막 getter 삭제

마지막 getter는 FallStrategy.getFalling 이다. 이것을 제거하는 절차도 동일하다.

  1. getter가 사용되는 모든 곳에서 오류가 발생하도록 getter를 비공개로 설정한다.
//변경 전
class FallStrategy {
  getFalling() {
    return this.falling;
  }
}

//변경 후
class FallStrategy {
  // ...
  private getFalling() { // 추가된 private 키워드
    return this.falling;
  }
}
  1. 클래스로의 코드 이관으로 오류를 수정한다.
// 변경 전
class Stone implements Tile {
  // ...
  moveHorizontal(dx: number){
    this.fallStrategy.getFalling()
    	.moveHorizontal(this, dx);
  }
}

class Box implements Tile {
  // ...
  moveHorizontal(dx: number){
    this.fallStrategy.getFalling()
    	.moveHorizontal(this, dx);
  }
}

class FallStrategy {
  // ...
}

// 변경 후
class Stone implements Tile {
  // ...
  moveHorizontal(dx: number){
    this.fallStrategy
    	.moveHorizontal(this, dx);
  }
}

class Box implements Tile {
  // ...
  moveHorizontal(dx: number){
    this.fallStrategy
    	.moveHorizontal(this, dx);
  }
}

class FallStrategy {
  // ...
  moveHorizontal(tile:Tile, dx:number){
    this.falling.moveHorizontal(tile, dx);
  }
}
  1. getter가 클래스로의 코드 이관의 일부로 인라인화 된다. 따라서 사용하지 않으므로 다른 사람이 사용하지 못하게 삭제한다.

FallStrategy를 살펴보면 몇 가지 다른 개선 사항이 있음을 알게된다. 먼저 삼항연산자 ?: 가 if문에서 else를 사용하지 말 것 규칙을 위반한다.
둘째 drop에 사용된 if 문은 falling과 연관돼 보인다. 삼항연산자로 시작해 줄을 Tile로 이관해서 제거할 수 있다.

//변경 전
interface Tile {
  //...
}
class Air implements Tile {
  //...
}
class Stone implements Tile {
  //...
}
class FallStrategy {
  //...
  update(tile: Tile, x:number, y:number){
    this.falling = map[y + 1][x].isAir()
    ? new Falling()
    : new Resting();
    this.drop(tile, x, y);
  }
}

// 변경 후
interface Tile {
  //...
  getBlockOnTopState(): FallingState;
}
class Air implements Tile {
  // ...
  getBlockOnTopState(){
    return new Falling();
  }
}
class Stone implements Tile {
  // ...
  getBlockOnTopState() {
    return new Resting();
  }
}
class FallStrategy {
  //...
  update(tile: Tile, x:number, y:number){
    this.falling = map[y + 1][x].getBlockOnTopState(); //이관된 코드
    this.drop(tile, x, y);
  }
}

FallStrategy.drop에서 메서드를 FallingState로 이전하고 FallStrategy.drop을 인라인화해서 if를 완전히 제거할 수 있다.

// 변경 전
interface FallingState {
  // ...
}
class Falling {
  // ...
}
class Resting {
  // ...
}
class FallStrategy {
  // ...
  update(tile: Tile, x:number, y:number){
    this.falling = 
      map[y + 1][x].getBlockOnTopState();
    this.drop(tile, x, y);
  }
  private drop(tile:Tile, x:number, y:number){
    if(this.falling.isFalling()){
      map[y + 1][x] = tile;
      map[y][x] = new Air();
    }
  }
}

// 변경 후
interface FallingState {
  // ...
  drop(tile: Tile, x:number, y:number): void;
} 
class Falling {
  // ...
  drop(tile: Tile, x:number, y:number){
    map[y + 1][x] = tile;
    map[y][x] = new Air();
  }
}
class Resting {
  // ...
  drop(tile: Tile, x: number, y:number) { }
}
class FallStrategy {
  // ...
  update(tile: Tile, x:number, y:number){
    this.falling = 
      map[y + 1][x].getBlockOnTopState();
    this.falling.drop(tile, x, y);
  }
}

6.2 간단한 데이터 캡슐화하기

6.2.1 규칙: 공통 접사를 사용하지 말 것

정의

코드에는 공통 접두사나 접미사가 있는 메서드나 변수가 없어야한다.

설명

  • 사용자 이름의 경우 username, 또는 타이머를 시작할 때의 동작의 경우 startTimer와 같이 해당 컨텍스트를 암시하기 위해 메서드나 변수에 접미사나 접두사를 붙인다. 이는 컨텍스트를 잘 전달하기 위함이다.
  • 여러 요소가 동일한 접사를 가질 때는 그 요소들의 긴밀성을 나타내기도한다.
  • 이런 구조를 전달 하는 더 좋은 방법은 클래스이다.

클래스를 사용해서 이런 메서드와 변수를 그룹화하는 장점은 외부 인터페이스를 완전하게 제어할 수 있다는 것이다.
가장 중요한 것은 데이터를 숨김으로써 해당 불변속성이 클래스 내에서 관리되게 하는 것이다. 그러면 지역 불변속성이 되어 유지보수하기 쉬워진다.

예제

deposit을 직접 호출하면 인출 없이 돈을 예금할 수 있었던 예를 생각해보자. deposit을 직접 호출하지 않아야 하기 때문에 이 기능을 구현하는 좋은 방법은 두 메서드를 클래스에 넣고 deposit을 비공개로 만드는 것이다.

// 나쁜 코드
function accountDeposit(to:string, amount:number){
  let accountId = database.find(to);
  database.updateOne(accountId, {$inc: {balance: amount}});
}
function accountTransfer(amount:number, from:string, to:string){
  accountDeposit(from, -amount);
  accountDeposit(to, amount);
}

// 좋은 코드
class Account {
  private deposit(to:string, amount:number)
  {
    let accountId = database.find(to)'
    database.updateOne(accountid, {$inc: {balance: amount}});
  }
  
  transfer(amount:number,from:string,to:string){
    this.deposit(from, -amount);
    this.deposit(to, amount);
  }
}

클래스에는 단 하나의 책임만 있어야한다!

단일 책임(특정 주제의 기능 집합)으로 클래스를 설계하려면 원칙과 개요가 필요하다. 이 규칙은 하위 책임을 식별하는 데 도움이 된다.
공통 접사가 암시하는 구조는 해당 메서드와 변수가 공통 접사의 책임을 공유한다는 것을 의미한다. 따라서 이런 메서드는 이 공통 책임을 전담하는 별도의 클래스에 있어야한다.

이 규칙은 또한 애플리케이션이 발전하고 시간이 지남에 따라 책임이 발생할 때 어떤 클래스가 책임을 가지는지 파악하는 데 도움이 된다. 클래스는 시간이 지나면서 큐모가 커질 떄가 많다.

6.2.2 규칙 적용하기

동일한 접사, 메서드 및 변수를 가진 확실한 그룹이 있다.

  • playerx
  • playery
  • drawPlayer

이것은 이것들을 Player라는 클래스로 옮겨야 함을 시사한다. 이미 Player이라는 클래스가 있지만 완전히 다른 목적을 갖고 있다.
기존 Player클래스를 PlayerTile로 변경하자!

이제 새로운 Player 클래스를 만들자!

  1. player 클래스를 생성한다.
class Player { }
  1. playerx와 playery를 Player로 옮기고 let을 private로 바꾼다. 변수명에서 player를 제거한다. 또 나중에 다룰 getter와 setter를 만든다.
// 변경 전
let playerx = 1;
let playery = 1;

// 변경 후
class Player { // 새로운 클래스
  private x = 1; // 이름에서 player를 삭제
  private y = 1;
  getX() {return this.x;}
  getY() {return this.y;}
  setX(x:number){this.x = x;}
  setY(y:number){this.y = y;}
}
  1. playerx와 playery는 더이상 전역 범위에 없기 때문에 컴파일러가 오류를 발생시켜 이들을 사용하는 모든 참조를 찾는 데 도움을 준다. 해당 오류를 다음 단계에 따라 바로잡는다.
    a Player 클래스의 인스턴스를 위한 적합한 변수명을 선택한다. (player)
    b player 변수가 있다고 가정하고 getter 또는 setter를 사용한다.
//변경 전
function moveToTile(newx:number, newy:number){
  map[playery][playerx] = new Air();
  map[newy][newx] = new PlayerTile();
  playerx = newx;
  playery = newy;
}

// 변경 후 
function moveToTile(newx: number, newy: number){
  map[player.getY()][player.geyX()] = new Air(); //접근이 getter로 변경 됨
  map[newy][newx] = new PlayerTile();
  player.setX(newX); //할당이 setter로 변경됨
  player.setY(newY);
}

c 두 개 이상의 다른 메서드에서 오류가 있을 경우 첫 번째 매개변수로 player:Player를 추가하고 인자로 player를 추가해서 새로운 오류를 발생시킨다.

// 변경 전
interface Tile {
  //...
  moveHorizontal(dx: number):void;
  moveVertical(dy:number):void; 
}

// 변경 후
interface Tile {
  //...
  moveHorizontal(player:Player, dx: number):void;
  moveVertical(player:Player, dy:number):void; 
}

d 한 메서드에서만 오류가 발생할 때까지 반복한다.
e 변수를 캡슐화했기 때문에 변수가 있던 지점에 let player = new Player() 를 넣는다.

let player = new Player();
// 변경 전
interface Tile {
   //...
  moveHorizontal(dx: number):void;
  moveVertical(dy:number):void; 
}

// ...
function moveToTile(newx:number, newy:number){
  map[playery][playerx] = new Air();
  map[newy][newx] = new PlayerTile();
  playerx = newx;
  playery = newy;
}

// ...
let playerx = 1;
let playery = 1; 

// 변경 후
interface Tile {
  //...
  moveHorizontal(player:Player, dx: number):void;
  moveVertical(player:Player, dy:number):void; 
}

function moveToTile(newx: number, newy: number){
  map[player.getY()][player.geyX()] = new Air(); //접근이 getter로 변경 됨
  map[newy][newx] = new PlayerTile();
  player.setX(newX); //할당이 setter로 변경됨
  player.setY(newY);
}

class Player { // 새로운 클래스
  private x = 1; // 이름에서 player를 삭제
  private y = 1;
  getX() {return this.x;}
  getY() {return this.y;}
  setX(x:number){this.x = x;}
  setY(y:number){this.y = y;}
}
let player = new Player();

클래스를 도입했기 때문에 이제 Player 접사가 있는 모든 메서드를 이 클래스로 이전 할 수 있다. draPlayer를 클래스로 이전하자.

// 변경 전
function drawPlay(palyer: Player, g: CanvasRenderingContext2D)
{
  g.fillStyle = "#ff0000";
  g.fillRect(
    player.getX() * TILE_SIZE, 
    player.getY() * TILE_SIZE, 
    TILE_SIZE, 
    TILE_SIZE);
}
class Player {
  // ...
}

// 변경 후
function drawPlay(palyer: Player, g: CanvasRenderingContext2D)
{
 player.draw(g);
}
class Player {
  // ...
  draw(g: CanvasRenderingContext2D){
    g.fillStyle = "#ff0000";
    g.fillRect(
      this.x * TILE_SIZE, 
      this.y * TILE_SIZE, 
      TILE_SIZE, 
      TILE_SIZE);
  }
}

drawPlayer에서 메서드의 인라인화를 수행한다.

이 새로운 클래스는 getter와 setter를 사용하지 말 것 이라는 새로운 규칙을 위반한다. 따라서 관련 리팩털이 패턴인 getter와 setter 제거하기를 적용한다.

  1. getter가 사용되는 모든 곳에서 오류를 발생시키기 위해 getter를 비공개로 설정한다.
// 변경 전
class Player{
  getX() {return this.x};
}
  
// 변경 후
class Player {
  private getX() {return this.x};
}
  1. 클래스로의 코드 이관을 사용해 오류를 수정한다.
//변경 전
class Right implements Input {
  handle(player: Player){
    map[player.getY()][player.getX() + 1]
    .moveHorizontal(player, 1);
  }
}
class Resting {
  //...
  moveHorizontal(player:Player, tile:Tile, dx:number){
    if(map[player.getY()][plater.getX()+dx + dx].isAir() 
       && !map[player.getY() + 1][player.getX()+dx].isAir())	{
      map[player.getY()][player.getX()+dx +dx] = tile;
      moveToTile(player,player.getX()+dx, player.getY());
    }
  }
}
/// ... 
  moveToTile(player, player.getX(), player.getY() + dy);
///...
function moveToTile(player:Player, newx:number, newy:number){
  map[player.getY()][player.getX()] = new Air();
  map[newy][newx] = new PlayerTile()'
  player.setX(newx);
  player.setY(newy);
}
//...
class Player {
  //...
}

// 변경 후
class Right implements Input {
  handle(player: Player){
    player.movehorizontal(1);
  }
}
class Resting {
  //...
  moveHorizontal(player:Player, tile:Tile, dx:number){
    player.pushHorizontal(tile, dx);
  }
}
/// ... 
  moveToTile(player, player.getX(), player.getY() + dy);
///...
player.move(0, dy);

function moveToTile(player:Player, newx:number, newy:number){
  player.moveToTile(newx, newy);
}
//...
class Player {
  //...
  
  moveHorizontal(dx: nmber){
    map[this.y][this.x + dx]
    .moveHorizontal(this, dx);
  }
  move(dx:number, dy:numer){
    this.moveToTile(this.x+dx, this.y+dy);
  }
  pushHorizontal(tile:Tile, dx:number){
    if(map[this.y][this.x+dx + dx].isAir() 
       && !map[this.y + 1][this.x+dx].isAir())	{
      map[this.y][this.x+dx +dx] = tile;
      moveToTile(this,this.x+dx, this.y);
    }
  }
 private movetoTile(newX: number, newY:nubmer){
    map[this.y][this.x] = new Air();
    map[newy][newx] = new PlayerTile();
    this.x = newx;
    this.y = newy;
  }
}
  1. getter는 클래스로의 코드 이관의 일부로 인라인화 돼서 사용하지 않기 떄문에 사용하지 않게 한다. 이때 setter도 함께 제거할 수 있다!
//변경 전 
class Player { // 새로운 클래스
  //...
  getX() {return this.x;}
  getY() {return this.y;}
  setX(x:number){this.x = x;}
  setY(y:number){this.y = y;}
}

//변경 후
class Player { // 새로운 클래스
  //...
  // 제거
}

6.2.3 리팩터링 패턴: 데이터 캡슐화

설명

  • 변수와 메서드를 캡슐화해서 접근할 수 있는 지점을 제한하고 구조를 명확하게 만들 수 있다.

  • 매서드를 캡슐화하면 이름을 단순하게 만들고 응집력을 더 명확하게 하는 데 도움이 된다.

  • 사람들은 클래스를 만드는 데 너무 소극적이다..

  • 데이터는 속성에 대한 어떤 전제(가정) 가 존재할 수 있다. 이러한 속성은 더 많은 위치에서 데이터에 접근할 수록 유지보수가 어려워진다.

  • 범위를 제한하면 클래스 내의 메서드만 데이터를 수정할 수 있으므로 이런 메서드들만 그 속성에 영향을 줄 수 있게 된다. 불변속성을 검증해야 할 경우 클래스 내부의 코드만 확인하면 된다.

절차

  1. 클래스를 만든다.
  2. 변수를 새로운 클래스로 이동하고 let을 private로 바꾼다. 변수의 이름을 단순한 것으로 정하고 변수에 대한 getter와 setter을 만든다.
  3. 변수가 더 이상 전역 범위에 없기 때문에 컴파일러가 오류를 발생시켜 모든 참조를 찾을 수 있게 한다. 다음의 다섯 절차를 통해 오류를 수정할 수 있다.
    a. 새 클래스의 인스턴스에 적합한 변수 이름을 선택한다.
    b. 접근을 가상의 변수에 대한 getter 또는 setter로 바꾼다.
    c. 2개 이상의 다른 메서드에서 오류가 발생한 경우 이전의 변수명을 가진 매개변수를 첫 번째 매개변수로 추가하고 동일한 변수를 첫 번째 인자로 호출하는 쪽에 넣는다.
    e. 변수를 캡슐화했다면 변수가 선언된 지점에서 새로운 클래스를 인스턴스화한다. 그렇지 않으면 오류가 발생한 메서드에 인스턴스화하는 부분을 만든다.
  1. 클래스를 만든다.
class Counter {}
  1. 변수를 새로운 클래스로 이동하고 let을 pivate로 변경한다. 변수의 이름을 단순화하고 변수에 대한 getter와 setter를 만든다.
//변경 전
let counter = 0;
class Counter {}

// 변경 후
class Counter {
   private counter = 0;
   getCounter() {return this.counter;}
  setCounter(c:number){
    this.counter = c;
  }
}
  1. counter가 더 이상 전역 범위에 없기 때문에 컴파일러는 참조하는 모든 지점에서 오류를 발생시킨다. 다음 다섯 단계를 따라 이 오류를 수정한다.

a. 새 클래스의 인스턴스에 적합한 변수 이름을 선택한다.
b. 접근을 가상의 변수에 대한 getter 또는 setter로 바꾼다.

//변경전
function incrementCounter(){
  counter++;
}
function main(){
  for(let i = 0; i < 20; i++){
    incrementCounter();
    console.log(counter);
  }
}

//변경 후
function incrementCounter(){
  counter.setCounter(counter.cetCounter() + 1); // 할당이 setter로 대체됨
}

function main() {
  for(let i = 0; i < 20; i++){
    incrementCounter()'
    console.log(counter.getCounter());
  }
}

c. 2개 이상의 다른 메서드에서 오류가 발생한 경우 이전의 변수명을 가진 매개변수를 첫 번째 매개변수로 추가하고 동일한 변수를 첫 번째 인자로 호출하는 쪽에 넣는다.

//변경 전
function incrementCounter(){
  counter.setCounter(counter.getCounter() + 1); // 할당이 setter로 대체됨
}

function main() {
  for(let i = 0; i < 20; i++){
    incrementCounter();
    console.log(counter.getCounter());
  }
}
//변경 후
function incrementCounter(counter:Counter){ //매개변수 추가
  counter.setCounter(counter.getCounter() + 1); 
}

function main() {
  for(let i = 0; i < 20; i++){
    incrementCounter(counter); // 인자로 전달된 가상의 변수
    console.log(counter.getCounter());
  }
}

d. 한 메서드에서만 오류가 발생할 때 까지 반복한다.
e. 실수로 루프 내에서 클래스를 초기화 할 수 있다. 코드가 루프 내에서 실행되는지 아닌지를 아는 것이 항상 쉽지는 않다. 다음 코드는 컴파일은 되지만 제대로 동작하지는 않는다.

function main() {
  for(let i =0; i < 20; i++){
    let counter = new Counter(); //잘못된 인스턴스화 위치
    incrementCounter(counter);
    console.log(counter.getCounter());
  }
}

이런 실수를 하지 않도록 변수를 캡슐화했는지 여부를 확인한다. 이 경우 캡슐화했으므로 변수가 있던 지점에서 새로운 클래스를 인스턴스화 한다.

//변경 전
class Counter {...}

//변경 후
class Counter {...}
let counter = new Counter(); // 이전 변수가 있던 위치에서 변수를 인스턴스화

6.3복잡한 데이터 캡슐화

이 책의 게임 코드베이스에는 메서드와 변수에 또 다른 명확한 그룹이 있다.

  • map
  • transformMap
  • updateMap
  • drawMap

이것들은 Map 클래스에 있어야 하므로 데이터 캡슐화를 적용한다.

  1. Map 클래스를 만든다.
class Map { }
  1. 변수 map을 Map 으로 옮기고 let을 private로 바꾼다. 이 경우 이름을 단순화할 수 없다. 또 map에 대한 getter와 setter를 만든다.
// 변경 전
let map: Title[][];

// 변경 후
class Map {
  private map: Tile[][]; // let을 private으로 변경해서 변수를 이동.
  getMap() {return this.map;}
  setMap(map:Tile[][]){this.map = map;}
}
  1. 이제 map이 전역 범위에 없기 때문에 컴파일러가 이를 사용하는 모든 곳에서 오류를 발생시킨다. 다음의 5단계로 이러한 오류를 수정한다.

a Map 클래스의 인스턴스에 적합한 변수 이름을 map으로 선택한다.
b 가상의 변수 map에 getter와 setter로 접근을 바꿉니다.

// 변경 전
function remove(shouldRemove:RemoveStrategy){
  for(let y = 0; y < map.length; y++){
    for (let x = 0; x < map[y].length; x++)
      if(shoudRemove.check(map[y][x]))
        map[y][x] = new Air();
  }
}
// 변경 후
function remove(shouldRemove:RemoveStrategy){
  for(let y = 0; y < map.getMap().length; y++){
    for (let x = 0; x < map.getMap[y].length; x++)
      if(shoudRemove.check(map.getMap()[y][x]))
        map.getMap()[y][x] = new Air();
  }
}

c 2개 이상의 다른 메서드에서 오류가 나는 경우 이전의 변수명을 가진 매개변수를 첫 번째 매개변수로 추가하고 같은 변수를 첫 번째 인자로 해서 호출하는 측에 넣어준다.

// 변경 전
interface Tile {
  // ...
  moveHorizontal(player: Player, dx:number):void;
  moveVertical(player: Player, dy:number):void;
  update(x:number, y:number):void;
}

// 변경 후
interface Tile {
  // ...
  // map이 인자로 추가됨
  moveHorizontal(map:Map, player: Player, dx:number):void;
  moveVertical(map: Map, player: Player, dy:number):void;
  update(map:Map, x:number, y:number):void;
}

// map이 여러 곳에 추가됨

d. 한 개의 메서드에서만 오류가 발생할 때까지 반복한다.
e. 변수를 캡슐화했으므로 map이 있던 곳에서 let map = new Map(); 을 넣는다.

let map = new Map();

이제 클래스로의 코드 이관으로 메서드의 이름을 단순화하고 이전에도 여러 번 했던것 처럼 메서드의 인라인화를 사용한다.

// 변경 전
function transformMap(map:Map){
  map.setMap(new Array(rawMap.length));
  for (let y = 0; y < rawMap.length; y++) {
    map.getMap()[y] = new Array(rawMap[y].length);
    for(let x = 0; x < rawMap[y].length; x++)
      map.getMap()[y][x] = transformTile(rawMap[y][x]);
  }
}

function updateMap(map: Map){
  for(let y = map.getMap().length -1; y >=0; y--)
    for(let x = 0; x < map.getMap()[y].length; x++)
      map.getMap()[y][x].update(map, x, y);
}

function drawMap(map: Map, g: CanvasRenderingContext2D){
  for (let y = 0; y < map.getMap().length; y++)
    for (let x = 0; x < map.getMap()[y].length; x++)
      map.getMap()[y][x].draw(g, x, y);
}

// 변경 후
class Map {
  
transformMap(){
  this.map = new Array(rawMap.length);
  for (let y = 0; y < rawMap.length; y++) {
    this.map[y] = new Array(rawMap[y].length);
    for(let x = 0; x < rawMap[y].length; x++)
      this.map[y][x] = transformTile(rawMap[y][x]);
  }
}

updateMap(){
  for(let y = this.map.length -1; y >= 0; y--)
    for(let x = 0; x < this.map[y].length; x++)
      this.map[y][x].update(this, x, y);
}

drawMap(g: CanvasRenderingContext2D){
  for (let y = 0; y < this.map.length; y++)
    for (let x = 0; x < this.map[y].length; x++)
      this.map[y][x].draw(g, x, y);
 }
}

getter와 setter가 있으므로 제거하기를 수행한다.

// 변경 전
class Falling {
  //...
  drop(map: Map, tile: Tile, x: number, y:number){
    map.getMap()[y + 1][x] = tile;
    map.getMap()[y][x] = new Air();
  }
}
class Map {
  //...
}

// 변경 후
class Falling {
  //...
 drop(map: Map, tile: Tile, x: number, y:number){
   map.drop(tile, x, y) // Map으로 이전된 코드
  }
}

class Map {
  // ...
  drop(tile: Tile, x: number, y:number){
    this.map[y + 1][x] = tile;
    this.map[y][x] = new Air();
  }
}
// 변경 전
class FallStrategy {
  // ...
  update(map: Map, tile: Tile, x:number, y:number){
    this.falling = map.getMap()[y + 1][x].isAir()
    ? new Falling()
    : new Resting();
    this.falling.drop(map, tile, x, y);
  }
}

class Map {
  //...
}

// 변경 후
class FallStrategy {
  // ...
  update(map: Map, tile: Tile, x:number, y:number){
   this.falling = map.getBlockOnTopState(x, y + 1); // map으로 이전된 코드.
   this.falling.drop(map, tile, x,y);
  }
}

class Map {
  //...
  getBlockOnTopState(x:number, y:number){
    return this.map[y][x].getBlockOnTopState();
  }
}
//변경 전
class Player {
  // ...
  moveHorizontal(map: Map, dx:number){
    map.getMap()[this.y][this.x + dx]
    .moveHorizontal(map, this, dx);
  }
  
  moveVertical(map: Map, dy:number){
    map.getMap()[this.y + dy][this.x]
    .moveVertical(map, this, dy);
  }
  
  pushHorizontal(map:Map, tile:Tile, dx:number){
    if(map.getMap()[this.y][this.x + dx + dx].isAir() 
    &&!map.getMap()[this.y + 1][this.x + dx].isAir()){
      map.getMap()[yhis.y][yhis.x + dx + dx] = tile;
      this.moveToTile(map, this.x + dx, this.y);
    }
  }
  private moveToTile(map: Map, newx:number, newy:number){
    map.movePlayer(this.x, this.y, newx, newy);
    map.getMap()[newy][newx] = new PlayerTile();
    this.x = newx;
    this.y = newy;
  }
}
class Map {
  // ...
}

// 변경 후
class Player {
  // ...
  moveHorizontal(map: Map, dx:number){
  	map.moveHorizontal(this, this.x, this.y, dx);
  }
  
  moveVertical(map: Map, dy:number){
    map.moveVertiacal(this, this.x, this.y, dy);
  }
  
  pushHorizontal(map:Map, tile:Tile, dx:number){
    if(map.isAir(this.x + dx + dx, this.y) 
    &&!map.isAir(this.x + dx, this.y + 1)){
      map.setTile(this.x + dx + dx, this.y, tile);
      this.moveToTile(map, this.x + dx, this.y);
    }
  }
  private moveToTile(map: Map, newx:number, newy:number){
    map.movePlayer(this.x, this.y, newx, newy);
    this.x = newx;
    this.y = newy;
  }
}

class Map {
  // ...
  isAir(x: number, y:number){
  	return this.map[y][x].isAir();
  }
  setTile(x: number, y:number, tile:Tile){
   this.map[y][x] = tile;
  }
  movePlayer(x:number, y:number, newx:number, newy:number){
    this.map[y][x] = new Air();
    this.map[newy][newx] = new PlayerTile();
  }
  moveHorizontal(player:Player, x:number, y:numer,dx:number){
    this.map[y][x + dx]
    .moveHorizontal(this, player, dx);
  }
  
  moveVertical(player:Player, x:number, y:number, dy:number) {
  	this.map[y + dy][x].moveVertical(this, player, dy);
  }
}
// 변경 전
function remove(map:Map, shouldRemove: RemoveStrategy){
  for (let y = 0; y < map.getMap().length; y++)
    for(let x= 0; x < map.getMap()[y].length; x++)
      if(shouldRemove.check(map.getMap()[y][x]))
        map.getMap()[y][x] = new Air();
}
class Map {
  // ...
  getMap() {
    return this.map;
  }
}

// 변경 후
class Map {
  // getMap 제거됨
  remove(shouldRemove: RemoveStrategy){
    for (let y = 0; y < this.map.length; y++)
    for(let x= 0; x < this.map[y].length; x++)
      if(shouldRemove.check(this.map[y][x]))
        this.map[y][x] = new Air();
  }
}

원래의 remove 함수는 이제 한 줄이므로 메서드의 인라인화를 사용한다.

일반적으로 setTile과 같이 강력한 메서드를 공개 인터페이스에 도입하는 것은 바람직하지 않다.
비공개 필드인 map에 대한 완전한 제어를 허락하는 것과 마찬가지이기 떄문이다. 그래도 일단은 코드를 계속 추가한다..
Player.pushHorizontal에 있는 한 줄을 제외한 모든 줄이 map을 사용한다는 것을 알 수 있다. 따라서 코드를 map으로 이전한다.

// 변경 전
class Player {
  pushHorizontal(map:Map, tile:Tile, dx:number){
    if(map.isAir(this.x + dx + dx, this.y) 
    &&!map.isAir(this.x + dx, this.y + 1)){
      map.setTile(this.x + dx + dx, this.y, tile);
      this.moveToTile(map, this.x + dx, this.y);
    }
  }
  private moveToTile(map: Map, newx:number, newy:number){
    map.movePlayer(this.x, this.y, newx, newy);
    this.x = newx;
    this.y = newy;
  }
}

// 변경 후
class Player {
  pushHorizontal(map:Map, tile:Tile, dx:number){
    map.pushHorizontal(this, tile, this.x, this.y, dx);//Map으로 이전된 코드.
  }
  moveToTile(map: Map, newx:number, newy:number){ // 공개 메서드로 만듦
    map.movePlayer(this.x, this.y, newx, newy);
    this.x = newx;
    this.y = newy;
  }
}
class Map {
  // ...
  pushHorizontal(player:Player, tile:Tile, x:number, y:number, dx:number)
  {
    if(this.map[y][x + dx + dx].isAir())
    &&!this.map[y + 1][x + dx].isAir()){
      this.map[y][x + dx + dx] = tilel
      player.moveToTile(this, x + dx, y);
    }
  }
}

setTile은 Map 내에서만 사용된다. 그럼 이를 비공개로 설정하거나 더 선호하는 코드 제거를 할 수 있다.

6.4 순서에 존재하는 불변속성 제거하기

map은 map.transform 호출로 초기화된다. 그러나 객체지향 프로그래밍에서는 초기화를 위한 다른 메커니즘이 있다. 바로 생성자이다.
여기서는 transform을 constructor로 변경하고 transform 호출을 제거한다.

// 변경 전
class Map {
  // ...
  transform(){
    //...
	}
}
/// ...
window.onload = () => {
  map.transform();
  gameLoop(map);
}
  
// 변경 후
class Map {
  // ...
  constructor() {
    // transform을 생성자로 변경
  }
}

window.onload = () => {
  // transform 호출 제거
  gameLoop(map);
}
  • 다른 메서드보다 먼저 map.transform을 호출해야하는 불변속성을 제거하는 효과가 있다.
  • 무언가가 다른것 보다 먼저 호출되어야할 때, 그것을 순서 불변속성(sequence invariant)라고 한다.
  • 생성자를 먼저 호출하지 않는 것은 불가능하기 때문에 이 불변속성이 제거된다. 이 기법은 항상 특정 순서로 일이 발생하게 하는 데 활용할 수 있다. 이것을 순서강제화 라고 한다.

절차

  1. 마지막으로 실행되어야 하는 메서드에 데이터 캡슐화를 적용한다.
  2. 생성자가 첫 번째 메서드를 호출하도록 한다.
  3. 두 메서드의 인자가 연결되어 있으면 이러한 인자를 필드로 만들고 메서드에서 제거한다.

예제

돈을 받는 사람의 잔고에서 금액을 더하기 전에 항상 보낸 사람의 잔고에서 먼저 금액을 빼야한다고 생각해보자. 따라서 순서는 금액의 음수 값으로 deposit을 호출한 후 금액의 양수 값으로 deposit을 호출해야한다.

  1. 마지막으로 실행되어야 하는 메서드에 데이터 캡슐화를 적용한다.
//변경 전
function deposit(to:string, amount:number){
  let accountId =  database.find(to);
  database.updateOne(accountId, {$inc : {blance:amount}});
}

//변경 후 
class Transfer { //새로운 클래스
  deposit(to:string, amount:number){
  let accountId =  database.find(to);
  database.updateOne(accountId, {$inc : {blance:amount}});
 }
}
  1. 생성자에서 첫 번째 메서드를 호출하도록 한다.
// 변경 전
class Transfer { //새로운 클래스
  deposit(to:string, amount:number){
  let accountId =  database.find(to);
  database.updateOne(accountId, {$inc : {blance:amount}});
 }
}

// 변경 후
class Transfer {
  constructor(from:string, amount:number){
    this.deposit(from, -amount);
  }
  
  deposit(to:string, amount:number){
  let accountId =  database.find(to);
  database.updateOne(accountId, {$inc : {blance:amount}});
 }
}

이제 송금자측의 계좌에서 음수의 amount로 deposit이 먼저 호출된다는 것을 보중할 수 있지만, 더 개선할 수 있다. amount 인자를 필드로 만들고 deposit 메서드에서 amount를 제거하여 두 amount를 연결할 수 있다.
한 번은 음수의 amount가 필요하기 때문에 도우미 메서드를 입한다.

class Transfer {
  constructor(from:string, private amount:number){
    this.depositHelper(fron, -this.amount);
  }
  private depositHelper(to:string, amount:number){
    let accountId =  database.find(to);
  	database.updateOne(accountId, {$inc : {blance:amount}});
  }
  deposit(to:string){
    this.depositHelper(to, this.amount);
  }
}

출금 없이 입금될 수 없다는 것을 보증할 수 있지만 수취인 (to)을 인자로 deposit을 호출하는 것을 잊어버리면 돈이 사라질 수 있다. 따라서 입금도 반드시 발생하도록 이 클래스를 다른 클래스로 감쌀 수도 있다.

6.5 열거형을 제거하는 또 다른 방법

열거형은 메서드를 가질 수 없다.

6.5.1 비공개 생성자를 통한 열거

사용하는 언어가 열거형에 대해 메서드를 지원하지 않는 경우 private 생성자를 사용해서 이를 우회한다. 모든 객체는 생성자를 호출해서 생성해야한다.
생성자를 private으로 만들면 클래스 내부에서만 객체를 생성할 수 있다.

// 열거형
enum TShirtSize {
  SMALL,
  MEDIUM,
  LARGE,
} 
function sizeToString(s: TShirtSize) {
  if (s === TShirtSize.SMALL)
    return "S";
  else if(s === TShirtSize.MEDIUM)
    return "M";
  else if(s === TShirtSize.LARGE)
    return "L";
}

//비공개 생성자
class TShirtSize {
  static readoly SMALL = new TShirtSize();
  static readoly MEDIUM = new TShirtSize();
  static readoly LARGE = new TShirtSize();
  private constructor() { }
}
function sizeToString(s: TShirtSize) {
  if (s === TShirtSize.SMALL)
    return "S";
  else if(s === TShirtSize.MEDIUM)
    return "M";
  else if(s === TShirtSize.LARGE)
    return "L";
}

이제 TShirtSize는 클래스이며, 이클래스에 코드를 넣을 수 있따. 불행히도 이 환경에서는 if문을 단순화 할 수 없다. 지난번과 달리 각 값에 대한 클래스가 없고 클래스는 하나만 존재하기 때문이다. 이 장점을 누리려면 클래스로의 타입 코드 대체가 필요하다.

//타입코드 값들을 대체하는 클래스
interface SizeValue { }
class SmallValue implements SizeValue { }
class MediumValue implements SizeValue { }
class LargeValue implements SizeValue { }

이 새로운 클래스를 비공개(privte) 생성자 클래스의 각 값에 대한 인자로 사용한다. 또한 인자를 필드로 저장한다.

// 변경 전
class TShirtSize {
  static readoly SMALL = new TShirtSize();
  static readoly MEDIUM = new TShirtSize();
  static readoly LARGE = new TShirtSize();
  private constructor() { }
}

// 변경 후
class TShirtSize {
  static readoly SMALL = new TShirtSize(new SmallValue());
  static readoly MEDIUM = new TShirtSize(new MediumValue());
  static readoly LARGE = new TShirtSize(new LargeValue());
  private constructor(private value: SizeValue) { }
}

TshirtSize에 무언가를 추가할 때 마다 SizeValue를 구현한 모든 클래스에 코드를 추가한다.

요약

  • 캡슐화를 시행하려면 데이터의 노출을 피해야한다. getter와 setter를 사용하지 말것 규칙은 getter와 setter를 통해 간접적으로 비공개 필드를 노출해서는 안 된다는 의미이다.
  • 공통 접사를 사용하지 말 것 규칙에 따르면 공통 접도사 또는 접미사가 있는 메서드와 변수가 있을 경우 한 클래스에 있어야한다.
  • 클래스를 이용한 순서 강제화 리팩터링 패턴을 사용하면 컴파일러가 실행 순서를 강제할 수 있도록 해서 순서 불변속성을 제거할 수 있다.
profile
잼나게 코딩하면서 살고 싶어요 ^O^/
post-custom-banner

0개의 댓글